@opengeni/api-router 0.23.1 → 0.26.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/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/cors.d.ts +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +22 -1
- package/dist/integrations/provider-oauth.d.ts +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +8 -2
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/scheduled-task-view.d.ts +168 -0
- package/dist/mcp/server.d.ts +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +55 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +274 -37
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +2 -0
- package/src/http/cors.ts +3 -0
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +487 -27
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +32 -1
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +923 -145
- package/src/memory-slack-delivery.ts +209 -0
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +284 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +421 -245
- package/src/routes/skills.ts +174 -0
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/channel-a.ts +723 -116
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +29 -9
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
|
@@ -0,0 +1,2543 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
environmentsEncryptionKeyBytes,
|
|
4
|
+
resolveFirstPartyDelegationSecret,
|
|
5
|
+
resolveStreamTokenSecret,
|
|
6
|
+
} from "@opengeni/config";
|
|
7
|
+
import {
|
|
8
|
+
BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
|
|
9
|
+
BROWSER_CONTROL_WEBSOCKET_PROTOCOL,
|
|
10
|
+
BROWSER_CONTROL_PROTOCOL_VERSION,
|
|
11
|
+
BROWSER_PROFILE_ARTIFACT_FORMAT,
|
|
12
|
+
BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
|
|
13
|
+
AttachedBrowserDevice,
|
|
14
|
+
AttachedBrowserDeviceListResponse,
|
|
15
|
+
BrowserActionCommand,
|
|
16
|
+
BrowserActionRequest,
|
|
17
|
+
BrowserDiagnosticKind,
|
|
18
|
+
BrowserOpenTargetRequest,
|
|
19
|
+
BrowserSessionAttachment,
|
|
20
|
+
BrowserSessionAttachmentRequest,
|
|
21
|
+
BrowserSessionHeartbeatResponse,
|
|
22
|
+
BrowserSessionLifecycleRequest,
|
|
23
|
+
BrowserSessionListResponse,
|
|
24
|
+
BrowserSessionMutationResponse,
|
|
25
|
+
BrowserTargetListResponse,
|
|
26
|
+
CreateBrowserSessionRequest,
|
|
27
|
+
InteractionActor,
|
|
28
|
+
PublishBrowserRevisionRequest,
|
|
29
|
+
PublishBrowserRevisionResponse,
|
|
30
|
+
type AccessGrant,
|
|
31
|
+
type BrowserSession as BrowserSessionValue,
|
|
32
|
+
type CreateBrowserSessionRequest as CreateBrowserSessionRequestValue,
|
|
33
|
+
type InteractionPlacement,
|
|
34
|
+
type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
|
|
35
|
+
type Session,
|
|
36
|
+
type SessionAuthorizationOperation,
|
|
37
|
+
} from "@opengeni/contracts";
|
|
38
|
+
import {
|
|
39
|
+
acquireLease,
|
|
40
|
+
activateBrowserSession,
|
|
41
|
+
ATTACHED_BROWSER_SESSION_CAPABILITIES,
|
|
42
|
+
AttachedBrowserDeviceNotFoundError,
|
|
43
|
+
BrowserIdentityConflictError,
|
|
44
|
+
BrowserIdentityNotFoundError,
|
|
45
|
+
BrowserIdentityStateError,
|
|
46
|
+
clearSuspendedBrowserSessionController,
|
|
47
|
+
BrowserSessionNotFoundError,
|
|
48
|
+
BrowserSessionOperationConflictError,
|
|
49
|
+
BrowserSessionStateError,
|
|
50
|
+
completeBrowserSessionEnd,
|
|
51
|
+
commitBrowserSessionSuspension,
|
|
52
|
+
commitBrowserRevisionPublication,
|
|
53
|
+
dispatchBrowserRevisionPublication,
|
|
54
|
+
dispatchBrowserSessionOperation,
|
|
55
|
+
failBrowserSessionOperation,
|
|
56
|
+
failBrowserSessionResume,
|
|
57
|
+
failBrowserSessionResumePreparation,
|
|
58
|
+
failBrowserSessionSuspension,
|
|
59
|
+
failBrowserRevisionPublication,
|
|
60
|
+
findBrowserSessionControlRecordByOperation,
|
|
61
|
+
getBrowserSessionControlRecord,
|
|
62
|
+
getComputerSessionControlRecord,
|
|
63
|
+
getBrowserPrivateCheckpointAuthority,
|
|
64
|
+
getAttachedBrowserDevice,
|
|
65
|
+
getBrowserRevisionArtifactAuthority,
|
|
66
|
+
getEnrollment,
|
|
67
|
+
getSession,
|
|
68
|
+
listBrowserSessions,
|
|
69
|
+
listAttachedBrowserDevices,
|
|
70
|
+
prepareBrowserSessionCreate,
|
|
71
|
+
prepareBrowserSessionEnd,
|
|
72
|
+
prepareBrowserSessionResume,
|
|
73
|
+
prepareBrowserSessionSuspend,
|
|
74
|
+
prepareBrowserRevisionPublication,
|
|
75
|
+
releaseLeaseHolder,
|
|
76
|
+
touchBrowserSessionController,
|
|
77
|
+
type BrowserSessionControlRecord,
|
|
78
|
+
type BrowserPrivateCheckpointAuthority,
|
|
79
|
+
type BrowserRevisionArtifactAuthority,
|
|
80
|
+
type LeaseSnapshot,
|
|
81
|
+
} from "@opengeni/db";
|
|
82
|
+
import {
|
|
83
|
+
requireAccessGrant,
|
|
84
|
+
requireSessionAuthorization,
|
|
85
|
+
relayConfigFromSettings,
|
|
86
|
+
SessionAuthorizationDeniedError,
|
|
87
|
+
SessionAuthorizationUnavailableError,
|
|
88
|
+
type ApiRouteDeps,
|
|
89
|
+
} from "@opengeni/core";
|
|
90
|
+
import {
|
|
91
|
+
BrowserControlProtocolError,
|
|
92
|
+
BrowserControlRequestError,
|
|
93
|
+
BrowserControlServerError,
|
|
94
|
+
BrowserControlServerUnsupportedError,
|
|
95
|
+
BrowserControlTransportError,
|
|
96
|
+
BrowserControlUnsupportedError,
|
|
97
|
+
buildStreamUrl,
|
|
98
|
+
buildSelfhostedBackendSession,
|
|
99
|
+
mintStreamToken,
|
|
100
|
+
NatsControlRpc,
|
|
101
|
+
NatsOpStreamTransport,
|
|
102
|
+
provisionBrowserControlClient,
|
|
103
|
+
type BrowserControlClient,
|
|
104
|
+
type BrowserControlPlacementSession,
|
|
105
|
+
type PlacementBrowserStateCaptureReceipt,
|
|
106
|
+
type PlacementBrowserTransport,
|
|
107
|
+
type RestorePlacementBrowserStateInput,
|
|
108
|
+
} from "@opengeni/runtime/sandbox";
|
|
109
|
+
import type { Context, Hono } from "hono";
|
|
110
|
+
import { HTTPException } from "hono/http-exception";
|
|
111
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
112
|
+
import {
|
|
113
|
+
deriveBrowserControllerAdminToken,
|
|
114
|
+
deriveBrowserSessionControllerTokens,
|
|
115
|
+
deriveBrowserViewGrantToken,
|
|
116
|
+
} from "../browser-controller-authority";
|
|
117
|
+
import {
|
|
118
|
+
browserStateArtifactAad,
|
|
119
|
+
browserStateManifestDigest,
|
|
120
|
+
browserStateObjectKey,
|
|
121
|
+
deriveBrowserStateDataKey,
|
|
122
|
+
unwrapBrowserStateDataKey,
|
|
123
|
+
wrapBrowserStateDataKey,
|
|
124
|
+
} from "../browser-state-authority";
|
|
125
|
+
import { allowedCorsOrigin } from "../http/cors";
|
|
126
|
+
import { withChannelA, type ChannelAOperation } from "../sandbox/channel-a";
|
|
127
|
+
|
|
128
|
+
const BROWSER_DRIVER_ID = "opengeni.cdp.v1";
|
|
129
|
+
|
|
130
|
+
type BrowserPlacement = {
|
|
131
|
+
placement: InteractionPlacement;
|
|
132
|
+
placementInstanceId: string;
|
|
133
|
+
session: BrowserControlPlacementSession;
|
|
134
|
+
lease: LeaseSnapshot | null;
|
|
135
|
+
transport: PlacementBrowserTransport;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
139
|
+
const channelServices = {
|
|
140
|
+
db: deps.db,
|
|
141
|
+
settings: deps.settings,
|
|
142
|
+
bus: deps.bus,
|
|
143
|
+
observability: deps.observability,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
app.get("/v1/workspaces/:workspaceId/attached-browsers", async (context) => {
|
|
147
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
148
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
149
|
+
const includeDisconnected = context.req.query("includeDisconnected") === "true";
|
|
150
|
+
return context.json(
|
|
151
|
+
AttachedBrowserDeviceListResponse.parse(
|
|
152
|
+
await listAttachedBrowserDevices(deps.db, {
|
|
153
|
+
accountId: grant.accountId,
|
|
154
|
+
workspaceId,
|
|
155
|
+
includeDisconnected,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
app.get("/v1/workspaces/:workspaceId/attached-browsers/:deviceId", async (context) => {
|
|
162
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
163
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
164
|
+
const deviceId = requireUuidParam(context, "deviceId");
|
|
165
|
+
try {
|
|
166
|
+
return context.json(
|
|
167
|
+
AttachedBrowserDevice.parse(
|
|
168
|
+
await getAttachedBrowserDevice(deps.db, {
|
|
169
|
+
accountId: grant.accountId,
|
|
170
|
+
workspaceId,
|
|
171
|
+
deviceId,
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof AttachedBrowserDeviceNotFoundError) {
|
|
177
|
+
throw new HTTPException(404, { message: error.message });
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
app.get("/v1/workspaces/:workspaceId/browser-sessions", async (context) => {
|
|
184
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
185
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
186
|
+
return context.json(
|
|
187
|
+
BrowserSessionListResponse.parse(
|
|
188
|
+
await listBrowserSessions(deps.db, {
|
|
189
|
+
accountId: grant.accountId,
|
|
190
|
+
workspaceId,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
app.get("/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId", async (context) => {
|
|
197
|
+
const workspaceId = context.req.param("workspaceId");
|
|
198
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
199
|
+
const browserSessionId = requireUuidParam(context, "browserSessionId");
|
|
200
|
+
try {
|
|
201
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
202
|
+
accountId: grant.accountId,
|
|
203
|
+
workspaceId,
|
|
204
|
+
browserSessionId,
|
|
205
|
+
});
|
|
206
|
+
await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.read");
|
|
207
|
+
return context.json(record.session);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw browserRouteError(error);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
app.post("/v1/workspaces/:workspaceId/browser-sessions", async (context) => {
|
|
214
|
+
const workspaceId = context.req.param("workspaceId");
|
|
215
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
216
|
+
const request = await parseJsonBody(context, CreateBrowserSessionRequest);
|
|
217
|
+
await authorizeSourceSession(deps, grant, request.sessionId, "session.control");
|
|
218
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
219
|
+
const authority = browserAuthorityRoot(deps);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const existing = await findBrowserSessionControlRecordByOperation(deps.db, {
|
|
223
|
+
accountId: grant.accountId,
|
|
224
|
+
workspaceId,
|
|
225
|
+
operationId: request.operationId,
|
|
226
|
+
});
|
|
227
|
+
if (existing && existing.sourceSessionId !== request.sessionId) {
|
|
228
|
+
throw new BrowserSessionOperationConflictError(
|
|
229
|
+
"BrowserSession create operation belongs to another source session",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (existing) assertCreateReplay(request, existing.session);
|
|
233
|
+
let prepared = existing
|
|
234
|
+
? await prepareBrowserSessionCreate(
|
|
235
|
+
deps.db,
|
|
236
|
+
browserCreateInput(grant, workspaceId, request, existing.session.placement),
|
|
237
|
+
)
|
|
238
|
+
: null;
|
|
239
|
+
if (prepared && isTerminalOperation(prepared.operation.state)) {
|
|
240
|
+
return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, request.sessionId);
|
|
244
|
+
const response = await withBrowserPlacement(
|
|
245
|
+
sourceSession,
|
|
246
|
+
grant,
|
|
247
|
+
existing?.session.placement ?? request.placement ?? null,
|
|
248
|
+
existing?.session.controller?.placementInstanceId ?? null,
|
|
249
|
+
"browser.create",
|
|
250
|
+
context.req.raw.signal,
|
|
251
|
+
async (placement) => {
|
|
252
|
+
let revisionAuthority: BrowserRevisionArtifactAuthority | null = null;
|
|
253
|
+
if (!prepared) {
|
|
254
|
+
prepared = await prepareBrowserSessionCreate(
|
|
255
|
+
deps.db,
|
|
256
|
+
browserCreateInput(grant, workspaceId, request, placement.placement),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (isTerminalOperation(prepared.operation.state)) return prepared;
|
|
260
|
+
|
|
261
|
+
if (prepared.session.baseRevisionId && !revisionAuthority) {
|
|
262
|
+
if (!prepared.session.identityId) {
|
|
263
|
+
throw new BrowserIdentityStateError("BrowserSession base revision has no identity");
|
|
264
|
+
}
|
|
265
|
+
revisionAuthority = await getBrowserRevisionArtifactAuthority(deps.db, {
|
|
266
|
+
accountId: grant.accountId,
|
|
267
|
+
workspaceId,
|
|
268
|
+
identityId: prepared.session.identityId,
|
|
269
|
+
revisionId: prepared.session.baseRevisionId,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const restore = await prepareBrowserStateRestore(
|
|
273
|
+
deps,
|
|
274
|
+
grant,
|
|
275
|
+
prepared.session,
|
|
276
|
+
placement.placement,
|
|
277
|
+
revisionAuthority,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const interactionHeld = await ensureInteractionHolder(
|
|
282
|
+
deps,
|
|
283
|
+
grant,
|
|
284
|
+
sourceSession,
|
|
285
|
+
prepared.session.id,
|
|
286
|
+
placement,
|
|
287
|
+
context.req.raw.signal,
|
|
288
|
+
);
|
|
289
|
+
const record = await ensureDispatchedGeneration(
|
|
290
|
+
deps,
|
|
291
|
+
grant,
|
|
292
|
+
workspaceId,
|
|
293
|
+
prepared.session.id,
|
|
294
|
+
request.operationId,
|
|
295
|
+
placement.placementInstanceId,
|
|
296
|
+
);
|
|
297
|
+
const controllerGeneration = requireOperationGeneration(record);
|
|
298
|
+
const linkedComputer = await requireLinkedComputerBinding(
|
|
299
|
+
deps,
|
|
300
|
+
grant,
|
|
301
|
+
prepared.session,
|
|
302
|
+
placement,
|
|
303
|
+
);
|
|
304
|
+
const adminToken = deriveBrowserControllerAdminToken({
|
|
305
|
+
rootSecret: authority,
|
|
306
|
+
accountId: grant.accountId,
|
|
307
|
+
workspaceId,
|
|
308
|
+
placement: placement.placement,
|
|
309
|
+
placementInstanceId: placement.placementInstanceId,
|
|
310
|
+
});
|
|
311
|
+
const tokens = deriveBrowserSessionControllerTokens({
|
|
312
|
+
rootSecret: authority,
|
|
313
|
+
accountId: grant.accountId,
|
|
314
|
+
workspaceId,
|
|
315
|
+
browserSessionId: prepared.session.id,
|
|
316
|
+
placement: placement.placement,
|
|
317
|
+
placementInstanceId: placement.placementInstanceId,
|
|
318
|
+
controllerGeneration,
|
|
319
|
+
tokenGeneration: record.tokenGeneration,
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
const { client } = await provisionBrowserControlClient(placement.session, {
|
|
323
|
+
adminToken,
|
|
324
|
+
nativeAuthority: nativeBrowserControllerAuthority(workspaceId, placement),
|
|
325
|
+
...(origin ? { allowedOrigins: [origin] } : {}),
|
|
326
|
+
});
|
|
327
|
+
await client.createSession({
|
|
328
|
+
browserSessionId: prepared.session.id,
|
|
329
|
+
controllerGeneration,
|
|
330
|
+
tokenGeneration: record.tokenGeneration,
|
|
331
|
+
...tokens,
|
|
332
|
+
headed: !prepared.session.headless,
|
|
333
|
+
transport: placement.transport,
|
|
334
|
+
...(linkedComputer ? { linkedComputer } : {}),
|
|
335
|
+
...(request.initialUrl ? { initialUrl: request.initialUrl } : {}),
|
|
336
|
+
...(restore ? { restore } : {}),
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (
|
|
340
|
+
error instanceof BrowserControlTransportError ||
|
|
341
|
+
(error instanceof BrowserControlRequestError && error.retryable) ||
|
|
342
|
+
isAbort(error)
|
|
343
|
+
)
|
|
344
|
+
throw error;
|
|
345
|
+
const failure = interactionFailure(error);
|
|
346
|
+
const failed = await failBrowserSessionOperation(deps.db, {
|
|
347
|
+
accountId: grant.accountId,
|
|
348
|
+
workspaceId,
|
|
349
|
+
operationId: request.operationId,
|
|
350
|
+
browserSessionId: prepared.session.id,
|
|
351
|
+
...(error instanceof BrowserControlProtocolError
|
|
352
|
+
? { state: "outcome_unknown" as const }
|
|
353
|
+
: {}),
|
|
354
|
+
error: failure,
|
|
355
|
+
});
|
|
356
|
+
if (interactionHeld && !(error instanceof BrowserControlProtocolError)) {
|
|
357
|
+
await releaseInteractionHolder(
|
|
358
|
+
deps,
|
|
359
|
+
grant,
|
|
360
|
+
workspaceId,
|
|
361
|
+
prepared.session.id,
|
|
362
|
+
placement.placement,
|
|
363
|
+
).catch(() => undefined);
|
|
364
|
+
}
|
|
365
|
+
return failed;
|
|
366
|
+
}
|
|
367
|
+
// Once the controller has accepted the exact binding, a persistence
|
|
368
|
+
// failure is retried against that same idempotent physical session.
|
|
369
|
+
// Never terminalize or release its placement authority here.
|
|
370
|
+
return await activateBrowserSession(deps.db, {
|
|
371
|
+
accountId: grant.accountId,
|
|
372
|
+
workspaceId,
|
|
373
|
+
operationId: request.operationId,
|
|
374
|
+
browserSessionId: prepared.session.id,
|
|
375
|
+
controller: {
|
|
376
|
+
controllerId: "opengeni-browserd",
|
|
377
|
+
controllerGeneration,
|
|
378
|
+
placementInstanceId: placement.placementInstanceId,
|
|
379
|
+
},
|
|
380
|
+
engineVersion: null,
|
|
381
|
+
});
|
|
382
|
+
} finally {
|
|
383
|
+
restore?.dataKey.fill(0);
|
|
384
|
+
restore?.aad.fill(0);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
const parsed = BrowserSessionMutationResponse.parse(response);
|
|
389
|
+
return context.json(
|
|
390
|
+
parsed,
|
|
391
|
+
parsed.operation.state === "completed" && !parsed.operation.replayed ? 201 : 200,
|
|
392
|
+
);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
throw browserRouteError(error);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
app.get(
|
|
399
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets",
|
|
400
|
+
async (context) => {
|
|
401
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
402
|
+
context,
|
|
403
|
+
"sessions:read",
|
|
404
|
+
);
|
|
405
|
+
const result = await withActiveBrowserController(
|
|
406
|
+
context,
|
|
407
|
+
grant,
|
|
408
|
+
workspaceId,
|
|
409
|
+
browserSessionId,
|
|
410
|
+
"session.read",
|
|
411
|
+
"browser.read",
|
|
412
|
+
async ({ sessionClient, binding }) =>
|
|
413
|
+
BrowserTargetListResponse.parse({
|
|
414
|
+
browserSessionId,
|
|
415
|
+
controllerGeneration: binding.controllerGeneration,
|
|
416
|
+
targets: await sessionClient.listTargets(),
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
return context.json(result);
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
app.post(
|
|
424
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets",
|
|
425
|
+
async (context) => {
|
|
426
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
427
|
+
context,
|
|
428
|
+
"sessions:control",
|
|
429
|
+
);
|
|
430
|
+
const request = await parseJsonBody(context, BrowserOpenTargetRequest);
|
|
431
|
+
const result = await withActiveBrowserController(
|
|
432
|
+
context,
|
|
433
|
+
grant,
|
|
434
|
+
workspaceId,
|
|
435
|
+
browserSessionId,
|
|
436
|
+
"session.control",
|
|
437
|
+
"browser.control",
|
|
438
|
+
async ({ sessionClient }) => await sessionClient.openTarget(request.url),
|
|
439
|
+
);
|
|
440
|
+
return context.json(result, 201);
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
app.post(
|
|
445
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/select",
|
|
446
|
+
async (context) => {
|
|
447
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
448
|
+
context,
|
|
449
|
+
"sessions:control",
|
|
450
|
+
);
|
|
451
|
+
await parseEmptyJsonBody(context);
|
|
452
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
453
|
+
const result = await withActiveBrowserController(
|
|
454
|
+
context,
|
|
455
|
+
grant,
|
|
456
|
+
workspaceId,
|
|
457
|
+
browserSessionId,
|
|
458
|
+
"session.control",
|
|
459
|
+
"browser.control",
|
|
460
|
+
async ({ sessionClient }) => await sessionClient.selectTarget(targetId),
|
|
461
|
+
);
|
|
462
|
+
return context.json(result);
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
app.delete(
|
|
467
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId",
|
|
468
|
+
async (context) => {
|
|
469
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
470
|
+
context,
|
|
471
|
+
"sessions:control",
|
|
472
|
+
);
|
|
473
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
474
|
+
const result = await withActiveBrowserController(
|
|
475
|
+
context,
|
|
476
|
+
grant,
|
|
477
|
+
workspaceId,
|
|
478
|
+
browserSessionId,
|
|
479
|
+
"session.control",
|
|
480
|
+
"browser.control",
|
|
481
|
+
async ({ sessionClient, binding }) =>
|
|
482
|
+
BrowserTargetListResponse.parse({
|
|
483
|
+
browserSessionId,
|
|
484
|
+
controllerGeneration: binding.controllerGeneration,
|
|
485
|
+
targets: await sessionClient.closeTarget(targetId),
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
return context.json(result);
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
app.get(
|
|
493
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/observation",
|
|
494
|
+
async (context) => {
|
|
495
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
496
|
+
context,
|
|
497
|
+
"sessions:read",
|
|
498
|
+
);
|
|
499
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
500
|
+
const result = await withActiveBrowserController(
|
|
501
|
+
context,
|
|
502
|
+
grant,
|
|
503
|
+
workspaceId,
|
|
504
|
+
browserSessionId,
|
|
505
|
+
"session.read",
|
|
506
|
+
"browser.read",
|
|
507
|
+
async ({ sessionClient }) => await sessionClient.observe(targetId),
|
|
508
|
+
);
|
|
509
|
+
return context.json(result);
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
app.post(
|
|
514
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/actions",
|
|
515
|
+
async (context) => {
|
|
516
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
517
|
+
context,
|
|
518
|
+
"sessions:control",
|
|
519
|
+
);
|
|
520
|
+
const request = await parseJsonBody(context, BrowserActionRequest);
|
|
521
|
+
const result = await withActiveBrowserController(
|
|
522
|
+
context,
|
|
523
|
+
grant,
|
|
524
|
+
workspaceId,
|
|
525
|
+
browserSessionId,
|
|
526
|
+
"session.control",
|
|
527
|
+
"browser.control",
|
|
528
|
+
async ({ sessionClient, binding }) =>
|
|
529
|
+
await sessionClient.action(
|
|
530
|
+
BrowserActionCommand.parse({
|
|
531
|
+
protocolVersion: BROWSER_CONTROL_PROTOCOL_VERSION,
|
|
532
|
+
operationId: request.operationId,
|
|
533
|
+
browserSessionId,
|
|
534
|
+
controllerGeneration: binding.controllerGeneration,
|
|
535
|
+
targetId: request.targetId,
|
|
536
|
+
expectedTargetGeneration: request.expectedTargetGeneration,
|
|
537
|
+
expectedDocumentGeneration: request.expectedDocumentGeneration,
|
|
538
|
+
expectedFrameId: request.expectedFrameId,
|
|
539
|
+
actor: interactionActorForGrant(grant),
|
|
540
|
+
action: request.action,
|
|
541
|
+
}),
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
return context.json(result);
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
app.get(
|
|
549
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/operations/:operationId",
|
|
550
|
+
async (context) => {
|
|
551
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
552
|
+
context,
|
|
553
|
+
"sessions:read",
|
|
554
|
+
);
|
|
555
|
+
const operationId = requireUuidParam(context, "operationId");
|
|
556
|
+
const result = await withActiveBrowserController(
|
|
557
|
+
context,
|
|
558
|
+
grant,
|
|
559
|
+
workspaceId,
|
|
560
|
+
browserSessionId,
|
|
561
|
+
"session.read",
|
|
562
|
+
"browser.read",
|
|
563
|
+
async ({ sessionClient }) => await sessionClient.receipt(operationId),
|
|
564
|
+
);
|
|
565
|
+
return context.json(result);
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
app.get(
|
|
570
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/diagnostics",
|
|
571
|
+
async (context) => {
|
|
572
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
573
|
+
context,
|
|
574
|
+
"sessions:read",
|
|
575
|
+
);
|
|
576
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
577
|
+
const kinds = diagnosticKinds(context.req.query("kinds"));
|
|
578
|
+
const afterSequence = optionalBoundedInteger(context.req.query("after"), 0, 2 ** 53 - 1);
|
|
579
|
+
const limit = optionalBoundedInteger(context.req.query("limit"), 1, 1_000);
|
|
580
|
+
const result = await withActiveBrowserController(
|
|
581
|
+
context,
|
|
582
|
+
grant,
|
|
583
|
+
workspaceId,
|
|
584
|
+
browserSessionId,
|
|
585
|
+
"session.read",
|
|
586
|
+
"browser.read",
|
|
587
|
+
async ({ sessionClient }) =>
|
|
588
|
+
await sessionClient.diagnostics(targetId, {
|
|
589
|
+
...(kinds ? { kinds } : {}),
|
|
590
|
+
...(afterSequence === null ? {} : { afterSequence }),
|
|
591
|
+
...(limit === null ? {} : { limit }),
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
|
+
return context.json(result);
|
|
595
|
+
},
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
app.post(
|
|
599
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/attachments",
|
|
600
|
+
async (context) => {
|
|
601
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
602
|
+
context,
|
|
603
|
+
"stream:view",
|
|
604
|
+
);
|
|
605
|
+
const request = await parseJsonBody(context, BrowserSessionAttachmentRequest);
|
|
606
|
+
const result = await withActiveBrowserController(
|
|
607
|
+
context,
|
|
608
|
+
grant,
|
|
609
|
+
workspaceId,
|
|
610
|
+
browserSessionId,
|
|
611
|
+
"session.viewer.read",
|
|
612
|
+
"browser.attach",
|
|
613
|
+
async ({ client, sessionClient, record, binding, placement }) => {
|
|
614
|
+
await sessionClient.observe(request.targetId);
|
|
615
|
+
const grantId = randomUUID();
|
|
616
|
+
const expiresAt = new Date(Date.now() + request.expiresInSeconds * 1_000).toISOString();
|
|
617
|
+
const rootSecret = browserAuthorityRoot(deps);
|
|
618
|
+
const relaySecret = placement.session.openBrowserFrames
|
|
619
|
+
? resolveStreamTokenSecret(deps.settings)
|
|
620
|
+
: null;
|
|
621
|
+
if (placement.session.openBrowserFrames && !relaySecret) {
|
|
622
|
+
throw new BrowserControlUnsupportedError(
|
|
623
|
+
"browser frame relay authority is unavailable",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const token = deriveBrowserViewGrantToken({
|
|
627
|
+
rootSecret,
|
|
628
|
+
accountId: grant.accountId,
|
|
629
|
+
workspaceId,
|
|
630
|
+
placement: record.session.placement,
|
|
631
|
+
placementInstanceId: placement.placementInstanceId,
|
|
632
|
+
browserSessionId,
|
|
633
|
+
controllerGeneration: binding.controllerGeneration,
|
|
634
|
+
tokenGeneration: record.tokenGeneration,
|
|
635
|
+
grantId,
|
|
636
|
+
expiresAt,
|
|
637
|
+
});
|
|
638
|
+
const reference = {
|
|
639
|
+
browserSessionId,
|
|
640
|
+
controllerGeneration: binding.controllerGeneration,
|
|
641
|
+
};
|
|
642
|
+
await client.createViewGrant(reference, { grantId, token, expiresAt });
|
|
643
|
+
const relayed = await client.openRelayedFrameStream({
|
|
644
|
+
reference,
|
|
645
|
+
targetId: request.targetId,
|
|
646
|
+
viewToken: token,
|
|
647
|
+
expiresAt,
|
|
648
|
+
...(request.stream ? { stream: request.stream } : {}),
|
|
649
|
+
});
|
|
650
|
+
const stream = relayed
|
|
651
|
+
? await (async () => {
|
|
652
|
+
const relayToken = await mintStreamToken(relaySecret!, {
|
|
653
|
+
workspaceId,
|
|
654
|
+
sessionId: record.sourceSessionId,
|
|
655
|
+
viewerId: grantId,
|
|
656
|
+
leaseEpoch: record.tokenGeneration,
|
|
657
|
+
port: relayed.channel.port,
|
|
658
|
+
ttlSeconds: request.expiresInSeconds,
|
|
659
|
+
});
|
|
660
|
+
return {
|
|
661
|
+
kind: "relay" as const,
|
|
662
|
+
url: buildStreamUrl(relayed.endpoint),
|
|
663
|
+
token: relayToken,
|
|
664
|
+
channel: {
|
|
665
|
+
channelId: relayed.channel.channelId,
|
|
666
|
+
workspaceId: relayed.channel.workspaceId,
|
|
667
|
+
agentId: relayed.channel.agentId,
|
|
668
|
+
kind: 3 as const,
|
|
669
|
+
port: relayed.channel.port,
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
})()
|
|
673
|
+
: {
|
|
674
|
+
kind: "direct_websocket" as const,
|
|
675
|
+
url: await client.frameStreamUrl(reference, request.targetId),
|
|
676
|
+
protocols: [
|
|
677
|
+
BROWSER_CONTROL_WEBSOCKET_PROTOCOL,
|
|
678
|
+
`${BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX}${token}`,
|
|
679
|
+
],
|
|
680
|
+
};
|
|
681
|
+
return BrowserSessionAttachment.parse({
|
|
682
|
+
browserSessionId,
|
|
683
|
+
controllerGeneration: binding.controllerGeneration,
|
|
684
|
+
targetId: request.targetId,
|
|
685
|
+
stream,
|
|
686
|
+
expiresAt,
|
|
687
|
+
});
|
|
688
|
+
},
|
|
689
|
+
);
|
|
690
|
+
return context.json(result, 201);
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
app.post(
|
|
695
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/heartbeat",
|
|
696
|
+
async (context) => {
|
|
697
|
+
const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
|
|
698
|
+
context,
|
|
699
|
+
"sessions:read",
|
|
700
|
+
);
|
|
701
|
+
await parseEmptyJsonBody(context);
|
|
702
|
+
const result = await withActiveBrowserController(
|
|
703
|
+
context,
|
|
704
|
+
grant,
|
|
705
|
+
workspaceId,
|
|
706
|
+
browserSessionId,
|
|
707
|
+
"session.read",
|
|
708
|
+
"browser.read",
|
|
709
|
+
async ({ sessionClient, binding }) => {
|
|
710
|
+
await sessionClient.listTargets();
|
|
711
|
+
return BrowserSessionHeartbeatResponse.parse({
|
|
712
|
+
browserSessionId,
|
|
713
|
+
controllerGeneration: binding.controllerGeneration,
|
|
714
|
+
alive: true,
|
|
715
|
+
});
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
return context.json(result);
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
app.post(
|
|
723
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/revisions",
|
|
724
|
+
async (context) => {
|
|
725
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
726
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
727
|
+
const browserSessionId = requireUuidParam(context, "browserSessionId");
|
|
728
|
+
const request = await parseJsonBody(context, PublishBrowserRevisionRequest);
|
|
729
|
+
try {
|
|
730
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
731
|
+
accountId: grant.accountId,
|
|
732
|
+
workspaceId,
|
|
733
|
+
browserSessionId,
|
|
734
|
+
});
|
|
735
|
+
await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.control");
|
|
736
|
+
const publicationInput = {
|
|
737
|
+
accountId: grant.accountId,
|
|
738
|
+
workspaceId,
|
|
739
|
+
operationId: request.operationId,
|
|
740
|
+
browserSessionId,
|
|
741
|
+
identityId: request.identityId,
|
|
742
|
+
expectedHeadGeneration: request.expectedHeadGeneration,
|
|
743
|
+
advanceDefault: request.advanceDefault,
|
|
744
|
+
actorSubjectId: grant.subjectId,
|
|
745
|
+
};
|
|
746
|
+
const prepared = await prepareBrowserRevisionPublication(deps.db, publicationInput);
|
|
747
|
+
if (prepared.kind === "completed") {
|
|
748
|
+
return context.json(PublishBrowserRevisionResponse.parse(prepared.response), 200);
|
|
749
|
+
}
|
|
750
|
+
const objectStorage = deps.objectStorage;
|
|
751
|
+
if (!objectStorage) {
|
|
752
|
+
throw new HTTPException(503, {
|
|
753
|
+
message: "browser state storage is not configured",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const response = await withActiveBrowserController(
|
|
758
|
+
context,
|
|
759
|
+
grant,
|
|
760
|
+
workspaceId,
|
|
761
|
+
browserSessionId,
|
|
762
|
+
"session.control",
|
|
763
|
+
"browser.control",
|
|
764
|
+
async ({ client, binding, record: activeRecord }) => {
|
|
765
|
+
if (
|
|
766
|
+
prepared.browserSessionId !== browserSessionId ||
|
|
767
|
+
prepared.controllerGeneration !== binding.controllerGeneration
|
|
768
|
+
) {
|
|
769
|
+
throw new BrowserIdentityConflictError(
|
|
770
|
+
"BrowserRevision publication belongs to a stale controller",
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const rootKey = requireBrowserStateRoot(deps);
|
|
774
|
+
const objectKey = browserStateObjectKey(workspaceId, request.operationId);
|
|
775
|
+
const dataKey = deriveBrowserStateDataKey(rootKey, {
|
|
776
|
+
accountId: grant.accountId,
|
|
777
|
+
workspaceId,
|
|
778
|
+
browserSessionId,
|
|
779
|
+
operationId: request.operationId,
|
|
780
|
+
objectKey,
|
|
781
|
+
});
|
|
782
|
+
const aad = browserStateArtifactAad({
|
|
783
|
+
accountId: grant.accountId,
|
|
784
|
+
workspaceId,
|
|
785
|
+
objectKey,
|
|
786
|
+
});
|
|
787
|
+
try {
|
|
788
|
+
const signed = await objectStorage.createPutUrl({
|
|
789
|
+
key: objectKey,
|
|
790
|
+
contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
|
|
791
|
+
});
|
|
792
|
+
const dispatched = await dispatchBrowserRevisionPublication(deps.db, {
|
|
793
|
+
...publicationInput,
|
|
794
|
+
controllerGeneration: binding.controllerGeneration,
|
|
795
|
+
});
|
|
796
|
+
if (dispatched.kind === "completed") return dispatched.response;
|
|
797
|
+
|
|
798
|
+
let receipt;
|
|
799
|
+
try {
|
|
800
|
+
receipt = await client.captureState({
|
|
801
|
+
browserSessionId,
|
|
802
|
+
controllerGeneration: binding.controllerGeneration,
|
|
803
|
+
operationId: request.operationId,
|
|
804
|
+
objectKey,
|
|
805
|
+
afterCapture: "restart",
|
|
806
|
+
dataKey,
|
|
807
|
+
aad,
|
|
808
|
+
upload: {
|
|
809
|
+
url: signed.url,
|
|
810
|
+
requiredHeaders: signed.requiredHeaders,
|
|
811
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
} catch (error) {
|
|
815
|
+
await settleBrowserRevisionCaptureFailure(
|
|
816
|
+
deps,
|
|
817
|
+
{
|
|
818
|
+
accountId: grant.accountId,
|
|
819
|
+
workspaceId,
|
|
820
|
+
operationId: request.operationId,
|
|
821
|
+
browserSessionId,
|
|
822
|
+
controllerGeneration: binding.controllerGeneration,
|
|
823
|
+
},
|
|
824
|
+
error,
|
|
825
|
+
);
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const materialization = browserRevisionMaterialization(
|
|
830
|
+
receipt.manifest,
|
|
831
|
+
activeRecord.session.placement,
|
|
832
|
+
);
|
|
833
|
+
const encryptedDataKey = wrapBrowserStateDataKey(rootKey, dataKey, {
|
|
834
|
+
accountId: grant.accountId,
|
|
835
|
+
workspaceId,
|
|
836
|
+
objectKey,
|
|
837
|
+
artifactDigest: receipt.artifactDigest,
|
|
838
|
+
contentDigest: receipt.contentDigest,
|
|
839
|
+
});
|
|
840
|
+
return await commitBrowserRevisionPublication(deps.db, {
|
|
841
|
+
...publicationInput,
|
|
842
|
+
controllerGeneration: binding.controllerGeneration,
|
|
843
|
+
manifestDigest: browserStateManifestDigest(receipt.manifest),
|
|
844
|
+
artifacts: [
|
|
845
|
+
{
|
|
846
|
+
kind: "chromium_profile",
|
|
847
|
+
format: receipt.format,
|
|
848
|
+
artifactDigest: receipt.artifactDigest,
|
|
849
|
+
contentDigest: receipt.contentDigest,
|
|
850
|
+
manifestDigest: browserStateManifestDigest(receipt.manifest),
|
|
851
|
+
objectKey,
|
|
852
|
+
encryptedDataKey,
|
|
853
|
+
sizeBytes: receipt.sizeBytes,
|
|
854
|
+
materialization,
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
});
|
|
858
|
+
} finally {
|
|
859
|
+
dataKey.fill(0);
|
|
860
|
+
rootKey.fill(0);
|
|
861
|
+
aad.fill(0);
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
const parsed = PublishBrowserRevisionResponse.parse(response);
|
|
866
|
+
return context.json(parsed, parsed.replayed ? 200 : 201);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
throw browserRouteError(error);
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
app.post(
|
|
874
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/suspend",
|
|
875
|
+
async (context) => {
|
|
876
|
+
const workspaceId = context.req.param("workspaceId");
|
|
877
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
878
|
+
const browserSessionId = requireUuidParam(context, "browserSessionId");
|
|
879
|
+
const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
|
|
880
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
881
|
+
try {
|
|
882
|
+
const before = await getBrowserSessionControlRecord(deps.db, {
|
|
883
|
+
accountId: grant.accountId,
|
|
884
|
+
workspaceId,
|
|
885
|
+
browserSessionId,
|
|
886
|
+
});
|
|
887
|
+
await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
|
|
888
|
+
browserAuthorityRoot(deps);
|
|
889
|
+
if (!deps.objectStorage) {
|
|
890
|
+
throw new HTTPException(503, {
|
|
891
|
+
message: "browser state storage is not configured",
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
const prepared = await prepareBrowserSessionSuspend(deps.db, {
|
|
895
|
+
accountId: grant.accountId,
|
|
896
|
+
workspaceId,
|
|
897
|
+
browserSessionId,
|
|
898
|
+
operationId: request.operationId,
|
|
899
|
+
actorSubjectId: grant.subjectId,
|
|
900
|
+
});
|
|
901
|
+
if (isTerminalOperation(prepared.operation.state)) {
|
|
902
|
+
if (prepared.operation.state === "completed") {
|
|
903
|
+
await finishSuspendedBrowserControllerCleanup(
|
|
904
|
+
context,
|
|
905
|
+
grant,
|
|
906
|
+
workspaceId,
|
|
907
|
+
browserSessionId,
|
|
908
|
+
);
|
|
909
|
+
return context.json(
|
|
910
|
+
BrowserSessionMutationResponse.parse({
|
|
911
|
+
session: (
|
|
912
|
+
await getBrowserSessionControlRecord(deps.db, {
|
|
913
|
+
accountId: grant.accountId,
|
|
914
|
+
workspaceId,
|
|
915
|
+
browserSessionId,
|
|
916
|
+
})
|
|
917
|
+
).session,
|
|
918
|
+
operation: prepared.operation,
|
|
919
|
+
}),
|
|
920
|
+
200,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
927
|
+
accountId: grant.accountId,
|
|
928
|
+
workspaceId,
|
|
929
|
+
browserSessionId,
|
|
930
|
+
operationId: request.operationId,
|
|
931
|
+
});
|
|
932
|
+
const binding = record.session.controller;
|
|
933
|
+
if (!binding) {
|
|
934
|
+
throw new BrowserSessionStateError("BrowserSession suspension controller is absent");
|
|
935
|
+
}
|
|
936
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
937
|
+
const response = await withBrowserPlacement(
|
|
938
|
+
sourceSession,
|
|
939
|
+
grant,
|
|
940
|
+
record.session.placement,
|
|
941
|
+
binding.placementInstanceId,
|
|
942
|
+
"browser.suspend",
|
|
943
|
+
context.req.raw.signal,
|
|
944
|
+
async (placement) => {
|
|
945
|
+
const objectStorage = deps.objectStorage!;
|
|
946
|
+
const rootKey = requireBrowserStateRoot(deps);
|
|
947
|
+
const objectKey = browserStateObjectKey(workspaceId, request.operationId);
|
|
948
|
+
const dataKey = deriveBrowserStateDataKey(rootKey, {
|
|
949
|
+
accountId: grant.accountId,
|
|
950
|
+
workspaceId,
|
|
951
|
+
browserSessionId,
|
|
952
|
+
operationId: request.operationId,
|
|
953
|
+
objectKey,
|
|
954
|
+
});
|
|
955
|
+
const aad = browserStateArtifactAad({
|
|
956
|
+
accountId: grant.accountId,
|
|
957
|
+
workspaceId,
|
|
958
|
+
objectKey,
|
|
959
|
+
});
|
|
960
|
+
try {
|
|
961
|
+
const signed = await objectStorage.createPutUrl({
|
|
962
|
+
key: objectKey,
|
|
963
|
+
contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
|
|
964
|
+
});
|
|
965
|
+
await dispatchBrowserSessionOperation(deps.db, {
|
|
966
|
+
accountId: grant.accountId,
|
|
967
|
+
workspaceId,
|
|
968
|
+
operationId: request.operationId,
|
|
969
|
+
browserSessionId,
|
|
970
|
+
controllerGeneration: binding.controllerGeneration,
|
|
971
|
+
});
|
|
972
|
+
const client = await provisionController(deps, grant, record, placement, origin);
|
|
973
|
+
let receipt: PlacementBrowserStateCaptureReceipt;
|
|
974
|
+
try {
|
|
975
|
+
receipt = await client.captureState({
|
|
976
|
+
browserSessionId,
|
|
977
|
+
controllerGeneration: binding.controllerGeneration,
|
|
978
|
+
operationId: request.operationId,
|
|
979
|
+
objectKey,
|
|
980
|
+
afterCapture: "stop",
|
|
981
|
+
dataKey,
|
|
982
|
+
aad,
|
|
983
|
+
upload: {
|
|
984
|
+
url: signed.url,
|
|
985
|
+
requiredHeaders: signed.requiredHeaders,
|
|
986
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
} catch (error) {
|
|
990
|
+
const failed = await settleBrowserSessionSuspensionCaptureFailure(
|
|
991
|
+
deps,
|
|
992
|
+
{
|
|
993
|
+
accountId: grant.accountId,
|
|
994
|
+
workspaceId,
|
|
995
|
+
operationId: request.operationId,
|
|
996
|
+
browserSessionId,
|
|
997
|
+
controllerGeneration: binding.controllerGeneration,
|
|
998
|
+
},
|
|
999
|
+
error,
|
|
1000
|
+
);
|
|
1001
|
+
if (failed) return failed;
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
const materialization = browserRevisionMaterialization(
|
|
1005
|
+
receipt.manifest,
|
|
1006
|
+
record.session.placement,
|
|
1007
|
+
);
|
|
1008
|
+
const manifestDigest = browserStateManifestDigest(receipt.manifest);
|
|
1009
|
+
const encryptedDataKey = wrapBrowserStateDataKey(rootKey, dataKey, {
|
|
1010
|
+
accountId: grant.accountId,
|
|
1011
|
+
workspaceId,
|
|
1012
|
+
objectKey,
|
|
1013
|
+
artifactDigest: receipt.artifactDigest,
|
|
1014
|
+
contentDigest: receipt.contentDigest,
|
|
1015
|
+
});
|
|
1016
|
+
const committed = await commitBrowserSessionSuspension(deps.db, {
|
|
1017
|
+
accountId: grant.accountId,
|
|
1018
|
+
workspaceId,
|
|
1019
|
+
operationId: request.operationId,
|
|
1020
|
+
browserSessionId,
|
|
1021
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1022
|
+
artifact: {
|
|
1023
|
+
kind: "chromium_profile",
|
|
1024
|
+
format: receipt.format,
|
|
1025
|
+
artifactDigest: receipt.artifactDigest,
|
|
1026
|
+
contentDigest: receipt.contentDigest,
|
|
1027
|
+
manifestDigest,
|
|
1028
|
+
objectKey,
|
|
1029
|
+
encryptedDataKey,
|
|
1030
|
+
sizeBytes: receipt.sizeBytes,
|
|
1031
|
+
materialization,
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
await endCapturedBrowserController(client, browserSessionId, binding);
|
|
1035
|
+
await clearSuspendedBrowserSessionController(deps.db, {
|
|
1036
|
+
accountId: grant.accountId,
|
|
1037
|
+
workspaceId,
|
|
1038
|
+
browserSessionId,
|
|
1039
|
+
expectedControllerGeneration: binding.controllerGeneration,
|
|
1040
|
+
});
|
|
1041
|
+
await releaseInteractionHolder(
|
|
1042
|
+
deps,
|
|
1043
|
+
grant,
|
|
1044
|
+
workspaceId,
|
|
1045
|
+
browserSessionId,
|
|
1046
|
+
record.session.placement,
|
|
1047
|
+
);
|
|
1048
|
+
return BrowserSessionMutationResponse.parse({
|
|
1049
|
+
session: (
|
|
1050
|
+
await getBrowserSessionControlRecord(deps.db, {
|
|
1051
|
+
accountId: grant.accountId,
|
|
1052
|
+
workspaceId,
|
|
1053
|
+
browserSessionId,
|
|
1054
|
+
})
|
|
1055
|
+
).session,
|
|
1056
|
+
operation: committed.operation,
|
|
1057
|
+
});
|
|
1058
|
+
} finally {
|
|
1059
|
+
dataKey.fill(0);
|
|
1060
|
+
rootKey.fill(0);
|
|
1061
|
+
aad.fill(0);
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
);
|
|
1065
|
+
return context.json(BrowserSessionMutationResponse.parse(response), 200);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
throw browserRouteError(error);
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
app.post(
|
|
1073
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/resume",
|
|
1074
|
+
async (context) => {
|
|
1075
|
+
const workspaceId = context.req.param("workspaceId");
|
|
1076
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
1077
|
+
const browserSessionId = requireUuidParam(context, "browserSessionId");
|
|
1078
|
+
const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
|
|
1079
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
1080
|
+
let restore: RestorePlacementBrowserStateInput | null = null;
|
|
1081
|
+
try {
|
|
1082
|
+
let before = await getBrowserSessionControlRecord(deps.db, {
|
|
1083
|
+
accountId: grant.accountId,
|
|
1084
|
+
workspaceId,
|
|
1085
|
+
browserSessionId,
|
|
1086
|
+
});
|
|
1087
|
+
await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
|
|
1088
|
+
if (before.session.lifecycle === "suspended" && before.session.controller) {
|
|
1089
|
+
await finishSuspendedBrowserControllerCleanup(
|
|
1090
|
+
context,
|
|
1091
|
+
grant,
|
|
1092
|
+
workspaceId,
|
|
1093
|
+
browserSessionId,
|
|
1094
|
+
);
|
|
1095
|
+
before = await getBrowserSessionControlRecord(deps.db, {
|
|
1096
|
+
accountId: grant.accountId,
|
|
1097
|
+
workspaceId,
|
|
1098
|
+
browserSessionId,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
browserAuthorityRoot(deps);
|
|
1102
|
+
const prepared = await prepareBrowserSessionResume(deps.db, {
|
|
1103
|
+
accountId: grant.accountId,
|
|
1104
|
+
workspaceId,
|
|
1105
|
+
browserSessionId,
|
|
1106
|
+
operationId: request.operationId,
|
|
1107
|
+
actorSubjectId: grant.subjectId,
|
|
1108
|
+
});
|
|
1109
|
+
if (isTerminalOperation(prepared.operation.state)) {
|
|
1110
|
+
return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const privateCheckpoint = await getBrowserPrivateCheckpointAuthority(deps.db, {
|
|
1114
|
+
accountId: grant.accountId,
|
|
1115
|
+
workspaceId,
|
|
1116
|
+
browserSessionId,
|
|
1117
|
+
});
|
|
1118
|
+
const revisionAuthority = privateCheckpoint
|
|
1119
|
+
? null
|
|
1120
|
+
: await requireBrowserRevisionRestoreAuthority(deps, grant, prepared.session);
|
|
1121
|
+
try {
|
|
1122
|
+
restore = privateCheckpoint
|
|
1123
|
+
? await prepareBrowserPrivateCheckpointRestore(
|
|
1124
|
+
deps,
|
|
1125
|
+
grant,
|
|
1126
|
+
prepared.session,
|
|
1127
|
+
privateCheckpoint,
|
|
1128
|
+
)
|
|
1129
|
+
: await prepareBrowserStateRestore(
|
|
1130
|
+
deps,
|
|
1131
|
+
grant,
|
|
1132
|
+
prepared.session,
|
|
1133
|
+
prepared.session.placement,
|
|
1134
|
+
revisionAuthority,
|
|
1135
|
+
);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
await failBrowserSessionResumePreparation(deps.db, {
|
|
1138
|
+
accountId: grant.accountId,
|
|
1139
|
+
workspaceId,
|
|
1140
|
+
operationId: request.operationId,
|
|
1141
|
+
browserSessionId,
|
|
1142
|
+
error: interactionFailure(error),
|
|
1143
|
+
});
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
if (!restore) {
|
|
1147
|
+
throw new BrowserSessionStateError("BrowserSession has no restorable durable state");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, before.sourceSessionId);
|
|
1151
|
+
const response = await withBrowserPlacement(
|
|
1152
|
+
sourceSession,
|
|
1153
|
+
grant,
|
|
1154
|
+
prepared.session.placement,
|
|
1155
|
+
null,
|
|
1156
|
+
"browser.resume",
|
|
1157
|
+
context.req.raw.signal,
|
|
1158
|
+
async (placement) => {
|
|
1159
|
+
const interactionHeld = await ensureInteractionHolder(
|
|
1160
|
+
deps,
|
|
1161
|
+
grant,
|
|
1162
|
+
sourceSession,
|
|
1163
|
+
browserSessionId,
|
|
1164
|
+
placement,
|
|
1165
|
+
context.req.raw.signal,
|
|
1166
|
+
);
|
|
1167
|
+
const record = await ensureDispatchedGeneration(
|
|
1168
|
+
deps,
|
|
1169
|
+
grant,
|
|
1170
|
+
workspaceId,
|
|
1171
|
+
browserSessionId,
|
|
1172
|
+
request.operationId,
|
|
1173
|
+
placement.placementInstanceId,
|
|
1174
|
+
);
|
|
1175
|
+
const controllerGeneration = requireOperationGeneration(record);
|
|
1176
|
+
const linkedComputer = await requireLinkedComputerBinding(
|
|
1177
|
+
deps,
|
|
1178
|
+
grant,
|
|
1179
|
+
prepared.session,
|
|
1180
|
+
placement,
|
|
1181
|
+
);
|
|
1182
|
+
const adminToken = deriveBrowserControllerAdminToken({
|
|
1183
|
+
rootSecret: browserAuthorityRoot(deps),
|
|
1184
|
+
accountId: grant.accountId,
|
|
1185
|
+
workspaceId,
|
|
1186
|
+
placement: placement.placement,
|
|
1187
|
+
placementInstanceId: placement.placementInstanceId,
|
|
1188
|
+
});
|
|
1189
|
+
const tokens = deriveBrowserSessionControllerTokens({
|
|
1190
|
+
rootSecret: browserAuthorityRoot(deps),
|
|
1191
|
+
accountId: grant.accountId,
|
|
1192
|
+
workspaceId,
|
|
1193
|
+
browserSessionId,
|
|
1194
|
+
placement: placement.placement,
|
|
1195
|
+
placementInstanceId: placement.placementInstanceId,
|
|
1196
|
+
controllerGeneration,
|
|
1197
|
+
tokenGeneration: record.tokenGeneration,
|
|
1198
|
+
});
|
|
1199
|
+
const { client } = await provisionBrowserControlClient(placement.session, {
|
|
1200
|
+
adminToken,
|
|
1201
|
+
nativeAuthority: nativeBrowserControllerAuthority(workspaceId, placement),
|
|
1202
|
+
...(origin ? { allowedOrigins: [origin] } : {}),
|
|
1203
|
+
});
|
|
1204
|
+
try {
|
|
1205
|
+
await client.createSession({
|
|
1206
|
+
browserSessionId,
|
|
1207
|
+
controllerGeneration,
|
|
1208
|
+
tokenGeneration: record.tokenGeneration,
|
|
1209
|
+
...tokens,
|
|
1210
|
+
headed: !prepared.session.headless,
|
|
1211
|
+
transport: placement.transport,
|
|
1212
|
+
...(linkedComputer ? { linkedComputer } : {}),
|
|
1213
|
+
restore: restore!,
|
|
1214
|
+
});
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
if (!isDefiniteBrowserControllerFailure(error)) throw error;
|
|
1217
|
+
try {
|
|
1218
|
+
await client.endSession(
|
|
1219
|
+
{ browserSessionId, controllerGeneration },
|
|
1220
|
+
{ removeState: true },
|
|
1221
|
+
);
|
|
1222
|
+
} catch (cleanupError) {
|
|
1223
|
+
if (
|
|
1224
|
+
!(
|
|
1225
|
+
cleanupError instanceof BrowserControlRequestError &&
|
|
1226
|
+
cleanupError.status === 404
|
|
1227
|
+
)
|
|
1228
|
+
) {
|
|
1229
|
+
throw cleanupError;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const failed = await failBrowserSessionResume(deps.db, {
|
|
1233
|
+
accountId: grant.accountId,
|
|
1234
|
+
workspaceId,
|
|
1235
|
+
operationId: request.operationId,
|
|
1236
|
+
browserSessionId,
|
|
1237
|
+
controllerGeneration,
|
|
1238
|
+
error: interactionFailure(error),
|
|
1239
|
+
});
|
|
1240
|
+
if (interactionHeld) {
|
|
1241
|
+
await releaseInteractionHolder(
|
|
1242
|
+
deps,
|
|
1243
|
+
grant,
|
|
1244
|
+
workspaceId,
|
|
1245
|
+
browserSessionId,
|
|
1246
|
+
prepared.session.placement,
|
|
1247
|
+
).catch(() => undefined);
|
|
1248
|
+
}
|
|
1249
|
+
return failed;
|
|
1250
|
+
}
|
|
1251
|
+
return await activateBrowserSession(deps.db, {
|
|
1252
|
+
accountId: grant.accountId,
|
|
1253
|
+
workspaceId,
|
|
1254
|
+
operationId: request.operationId,
|
|
1255
|
+
browserSessionId,
|
|
1256
|
+
controller: {
|
|
1257
|
+
controllerId: "opengeni-browserd",
|
|
1258
|
+
controllerGeneration,
|
|
1259
|
+
placementInstanceId: placement.placementInstanceId,
|
|
1260
|
+
},
|
|
1261
|
+
engineVersion: null,
|
|
1262
|
+
});
|
|
1263
|
+
},
|
|
1264
|
+
);
|
|
1265
|
+
return context.json(BrowserSessionMutationResponse.parse(response), 200);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
throw browserRouteError(error);
|
|
1268
|
+
} finally {
|
|
1269
|
+
restore?.dataKey.fill(0);
|
|
1270
|
+
restore?.aad.fill(0);
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
app.post(
|
|
1276
|
+
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/end",
|
|
1277
|
+
async (context) => {
|
|
1278
|
+
const workspaceId = context.req.param("workspaceId");
|
|
1279
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
1280
|
+
const browserSessionId = requireUuidParam(context, "browserSessionId");
|
|
1281
|
+
const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
|
|
1282
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
1283
|
+
try {
|
|
1284
|
+
const before = await getBrowserSessionControlRecord(deps.db, {
|
|
1285
|
+
accountId: grant.accountId,
|
|
1286
|
+
workspaceId,
|
|
1287
|
+
browserSessionId,
|
|
1288
|
+
});
|
|
1289
|
+
await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
|
|
1290
|
+
if (before.session.controller) browserAuthorityRoot(deps);
|
|
1291
|
+
const prepared = await prepareBrowserSessionEnd(deps.db, {
|
|
1292
|
+
accountId: grant.accountId,
|
|
1293
|
+
workspaceId,
|
|
1294
|
+
browserSessionId,
|
|
1295
|
+
operationId: request.operationId,
|
|
1296
|
+
actorSubjectId: grant.subjectId,
|
|
1297
|
+
});
|
|
1298
|
+
if (isTerminalOperation(prepared.operation.state)) {
|
|
1299
|
+
if (prepared.operation.state === "completed") {
|
|
1300
|
+
await releaseInteractionHolder(
|
|
1301
|
+
deps,
|
|
1302
|
+
grant,
|
|
1303
|
+
workspaceId,
|
|
1304
|
+
browserSessionId,
|
|
1305
|
+
before.session.placement,
|
|
1306
|
+
).catch(() => undefined);
|
|
1307
|
+
}
|
|
1308
|
+
return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
1312
|
+
accountId: grant.accountId,
|
|
1313
|
+
workspaceId,
|
|
1314
|
+
browserSessionId,
|
|
1315
|
+
operationId: request.operationId,
|
|
1316
|
+
});
|
|
1317
|
+
const binding = record.session.controller;
|
|
1318
|
+
if (!binding) {
|
|
1319
|
+
const completed = await completeBrowserSessionEnd(deps.db, {
|
|
1320
|
+
accountId: grant.accountId,
|
|
1321
|
+
workspaceId,
|
|
1322
|
+
operationId: request.operationId,
|
|
1323
|
+
browserSessionId,
|
|
1324
|
+
expectedControllerGeneration: null,
|
|
1325
|
+
});
|
|
1326
|
+
await releaseInteractionHolder(
|
|
1327
|
+
deps,
|
|
1328
|
+
grant,
|
|
1329
|
+
workspaceId,
|
|
1330
|
+
browserSessionId,
|
|
1331
|
+
record.session.placement,
|
|
1332
|
+
).catch(() => undefined);
|
|
1333
|
+
return context.json(BrowserSessionMutationResponse.parse(completed), 200);
|
|
1334
|
+
}
|
|
1335
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
1336
|
+
const response = await withBrowserPlacement(
|
|
1337
|
+
sourceSession,
|
|
1338
|
+
grant,
|
|
1339
|
+
record.session.placement,
|
|
1340
|
+
binding.placementInstanceId,
|
|
1341
|
+
"browser.end",
|
|
1342
|
+
context.req.raw.signal,
|
|
1343
|
+
async (placement) => {
|
|
1344
|
+
await dispatchBrowserSessionOperation(deps.db, {
|
|
1345
|
+
accountId: grant.accountId,
|
|
1346
|
+
workspaceId,
|
|
1347
|
+
operationId: request.operationId,
|
|
1348
|
+
browserSessionId,
|
|
1349
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1350
|
+
});
|
|
1351
|
+
const client = await provisionController(deps, grant, record, placement, origin);
|
|
1352
|
+
try {
|
|
1353
|
+
await client.endSession(
|
|
1354
|
+
{
|
|
1355
|
+
browserSessionId,
|
|
1356
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1357
|
+
},
|
|
1358
|
+
{ removeState: true },
|
|
1359
|
+
);
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
// Physical absence already satisfies an exact end. A stale live
|
|
1362
|
+
// generation is 409 and transport uncertainty remains retryable.
|
|
1363
|
+
if (!(error instanceof BrowserControlRequestError && error.status === 404)) {
|
|
1364
|
+
throw error;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const completed = await completeBrowserSessionEnd(deps.db, {
|
|
1368
|
+
accountId: grant.accountId,
|
|
1369
|
+
workspaceId,
|
|
1370
|
+
operationId: request.operationId,
|
|
1371
|
+
browserSessionId,
|
|
1372
|
+
expectedControllerGeneration: binding.controllerGeneration,
|
|
1373
|
+
});
|
|
1374
|
+
await releaseInteractionHolder(
|
|
1375
|
+
deps,
|
|
1376
|
+
grant,
|
|
1377
|
+
workspaceId,
|
|
1378
|
+
browserSessionId,
|
|
1379
|
+
record.session.placement,
|
|
1380
|
+
).catch(() => undefined);
|
|
1381
|
+
return completed;
|
|
1382
|
+
},
|
|
1383
|
+
);
|
|
1384
|
+
return context.json(BrowserSessionMutationResponse.parse(response), 200);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
throw browserRouteError(error);
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
async function withBrowserPlacement<T>(
|
|
1392
|
+
sourceSession: Session,
|
|
1393
|
+
grant: AccessGrant,
|
|
1394
|
+
expectedPlacement: InteractionPlacement | null,
|
|
1395
|
+
expectedPlacementInstanceId: string | null,
|
|
1396
|
+
operation: ChannelAOperation,
|
|
1397
|
+
waitSignal: AbortSignal,
|
|
1398
|
+
callback: (placement: BrowserPlacement) => Promise<T>,
|
|
1399
|
+
): Promise<T> {
|
|
1400
|
+
if (expectedPlacement?.kind === "attached_device") {
|
|
1401
|
+
waitSignal.throwIfAborted();
|
|
1402
|
+
const device = await getAttachedBrowserDevice(deps.db, {
|
|
1403
|
+
accountId: grant.accountId,
|
|
1404
|
+
workspaceId: sourceSession.workspaceId,
|
|
1405
|
+
deviceId: expectedPlacement.deviceId,
|
|
1406
|
+
});
|
|
1407
|
+
if (device.state !== "connected") {
|
|
1408
|
+
throw new BrowserSessionStateError("Attached browser is disconnected");
|
|
1409
|
+
}
|
|
1410
|
+
const enrollment = await getEnrollment(
|
|
1411
|
+
deps.db,
|
|
1412
|
+
sourceSession.workspaceId,
|
|
1413
|
+
device.enrollmentId,
|
|
1414
|
+
);
|
|
1415
|
+
if (!enrollment || enrollment.status !== "active") {
|
|
1416
|
+
throw new BrowserSessionStateError("Attached browser machine is unavailable");
|
|
1417
|
+
}
|
|
1418
|
+
assertPlacementInstance(expectedPlacementInstanceId, device.connectionGeneration);
|
|
1419
|
+
const built = await buildSelfhostedBackendSession({
|
|
1420
|
+
workspaceId: sourceSession.workspaceId,
|
|
1421
|
+
agentId: device.enrollmentId,
|
|
1422
|
+
relay: relayConfigFromSettings(deps.settings),
|
|
1423
|
+
controlRpcFactory: () => new NatsControlRpc(async () => deps.bus.getRequestConnection()),
|
|
1424
|
+
// Browser-profile generation is the physical controller fence. Epoch 0
|
|
1425
|
+
// deliberately addresses the enrollment itself rather than borrowing an
|
|
1426
|
+
// unrelated session routing epoch.
|
|
1427
|
+
epoch: 0,
|
|
1428
|
+
timeoutMs: deps.settings.sandboxSelfhostedControlTimeoutMs,
|
|
1429
|
+
execTimeoutMs: deps.settings.sandboxSelfhostedExecTimeoutMs,
|
|
1430
|
+
...(deps.settings.agentOpStreamEnabled === true &&
|
|
1431
|
+
enrollment.opStream === true &&
|
|
1432
|
+
deps.bus.getOpStreamConnection
|
|
1433
|
+
? {
|
|
1434
|
+
opStream: {
|
|
1435
|
+
transport: new NatsOpStreamTransport(
|
|
1436
|
+
async () => deps.bus.getOpStreamConnection?.() ?? null,
|
|
1437
|
+
),
|
|
1438
|
+
},
|
|
1439
|
+
}
|
|
1440
|
+
: {}),
|
|
1441
|
+
});
|
|
1442
|
+
waitSignal.throwIfAborted();
|
|
1443
|
+
return await callback({
|
|
1444
|
+
placement: expectedPlacement,
|
|
1445
|
+
placementInstanceId: device.connectionGeneration,
|
|
1446
|
+
session: built.session as unknown as BrowserControlPlacementSession,
|
|
1447
|
+
lease: null,
|
|
1448
|
+
transport: {
|
|
1449
|
+
kind: "attached_chrome",
|
|
1450
|
+
deviceId: device.id,
|
|
1451
|
+
connectionGeneration: device.connectionGeneration,
|
|
1452
|
+
browserName: device.browserName,
|
|
1453
|
+
browserVersion: device.browserVersion,
|
|
1454
|
+
},
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
return await withChannelA(
|
|
1458
|
+
channelServices,
|
|
1459
|
+
{
|
|
1460
|
+
accountId: grant.accountId,
|
|
1461
|
+
workspaceId: sourceSession.workspaceId,
|
|
1462
|
+
session: sourceSession,
|
|
1463
|
+
subjectId: grant.subjectId,
|
|
1464
|
+
waitSignal,
|
|
1465
|
+
operation,
|
|
1466
|
+
},
|
|
1467
|
+
async (handle) => {
|
|
1468
|
+
if (expectedPlacement?.kind === "sandbox_group") {
|
|
1469
|
+
if (
|
|
1470
|
+
sourceSession.sandboxGroupId !== expectedPlacement.sandboxGroupId ||
|
|
1471
|
+
!handle.lease?.instanceId
|
|
1472
|
+
) {
|
|
1473
|
+
throw new BrowserSessionStateError("BrowserSession home placement is unavailable");
|
|
1474
|
+
}
|
|
1475
|
+
assertPlacementInstance(expectedPlacementInstanceId, handle.lease.instanceId);
|
|
1476
|
+
return await callback({
|
|
1477
|
+
placement: expectedPlacement,
|
|
1478
|
+
placementInstanceId: handle.lease.instanceId,
|
|
1479
|
+
session: handle.homeSession,
|
|
1480
|
+
lease: handle.lease,
|
|
1481
|
+
transport: { kind: "managed" },
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const resolved = await handle.routingSession.prime();
|
|
1486
|
+
if (expectedPlacement?.kind === "connected_machine") {
|
|
1487
|
+
if (
|
|
1488
|
+
resolved.kind !== "selfhosted" ||
|
|
1489
|
+
resolved.sandboxId !== expectedPlacement.sandboxId
|
|
1490
|
+
) {
|
|
1491
|
+
throw new BrowserSessionStateError(
|
|
1492
|
+
"BrowserSession Connected Machine is not the active placement",
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
const placementInstanceId = resolved.providerInstanceId ?? expectedPlacement.sandboxId;
|
|
1496
|
+
assertPlacementInstance(expectedPlacementInstanceId, placementInstanceId);
|
|
1497
|
+
return await callback({
|
|
1498
|
+
placement: expectedPlacement,
|
|
1499
|
+
placementInstanceId,
|
|
1500
|
+
session: resolved.session as unknown as BrowserControlPlacementSession,
|
|
1501
|
+
lease: null,
|
|
1502
|
+
transport: { kind: "managed" },
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
if (expectedPlacement) {
|
|
1506
|
+
throw new BrowserControlUnsupportedError(
|
|
1507
|
+
`browser placement ${expectedPlacement.kind} is not executable yet`,
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (resolved.sandboxId === null) {
|
|
1512
|
+
if (!handle.lease?.instanceId) {
|
|
1513
|
+
throw new BrowserSessionStateError("BrowserSession home placement is unavailable");
|
|
1514
|
+
}
|
|
1515
|
+
if (
|
|
1516
|
+
resolved.providerInstanceId &&
|
|
1517
|
+
resolved.providerInstanceId !== handle.lease.instanceId
|
|
1518
|
+
) {
|
|
1519
|
+
throw new BrowserSessionStateError("BrowserSession home placement fence changed");
|
|
1520
|
+
}
|
|
1521
|
+
return await callback({
|
|
1522
|
+
placement: {
|
|
1523
|
+
kind: "sandbox_group",
|
|
1524
|
+
sandboxGroupId: sourceSession.sandboxGroupId,
|
|
1525
|
+
},
|
|
1526
|
+
placementInstanceId: handle.lease.instanceId,
|
|
1527
|
+
session: handle.homeSession,
|
|
1528
|
+
lease: handle.lease,
|
|
1529
|
+
transport: { kind: "managed" },
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
if (resolved.kind === "selfhosted") {
|
|
1533
|
+
const placement = {
|
|
1534
|
+
kind: "connected_machine" as const,
|
|
1535
|
+
sandboxId: resolved.sandboxId,
|
|
1536
|
+
};
|
|
1537
|
+
return await callback({
|
|
1538
|
+
placement,
|
|
1539
|
+
placementInstanceId: resolved.providerInstanceId ?? resolved.sandboxId,
|
|
1540
|
+
session: resolved.session as unknown as BrowserControlPlacementSession,
|
|
1541
|
+
lease: null,
|
|
1542
|
+
transport: { kind: "managed" },
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
throw new BrowserControlUnsupportedError(
|
|
1546
|
+
"browser creation on a non-home provider sandbox is not supported",
|
|
1547
|
+
);
|
|
1548
|
+
},
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
async function browserRoutePreamble(
|
|
1553
|
+
context: Context,
|
|
1554
|
+
permission: "sessions:read" | "sessions:control" | "stream:view",
|
|
1555
|
+
): Promise<{
|
|
1556
|
+
workspaceId: string;
|
|
1557
|
+
grant: AccessGrant;
|
|
1558
|
+
browserSessionId: string;
|
|
1559
|
+
}> {
|
|
1560
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
1561
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, permission);
|
|
1562
|
+
return {
|
|
1563
|
+
workspaceId,
|
|
1564
|
+
grant,
|
|
1565
|
+
browserSessionId: requireUuidParam(context, "browserSessionId"),
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function withActiveBrowserController<T>(
|
|
1570
|
+
context: Context,
|
|
1571
|
+
grant: AccessGrant,
|
|
1572
|
+
workspaceId: string,
|
|
1573
|
+
browserSessionId: string,
|
|
1574
|
+
authorizationOperation: SessionAuthorizationOperation,
|
|
1575
|
+
channelOperation: ChannelAOperation,
|
|
1576
|
+
callback: (input: {
|
|
1577
|
+
client: BrowserControlClient;
|
|
1578
|
+
sessionClient: ReturnType<BrowserControlClient["sessionClient"]>;
|
|
1579
|
+
record: BrowserSessionControlRecord;
|
|
1580
|
+
binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
|
|
1581
|
+
placement: BrowserPlacement;
|
|
1582
|
+
}) => Promise<T>,
|
|
1583
|
+
): Promise<T> {
|
|
1584
|
+
try {
|
|
1585
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
1586
|
+
accountId: grant.accountId,
|
|
1587
|
+
workspaceId,
|
|
1588
|
+
browserSessionId,
|
|
1589
|
+
});
|
|
1590
|
+
await authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation);
|
|
1591
|
+
if (record.session.lifecycle !== "active" || !record.session.controller) {
|
|
1592
|
+
throw new BrowserSessionStateError("BrowserSession is not active");
|
|
1593
|
+
}
|
|
1594
|
+
const binding = record.session.controller;
|
|
1595
|
+
const admitted = await touchBrowserSessionController(deps.db, {
|
|
1596
|
+
accountId: grant.accountId,
|
|
1597
|
+
workspaceId,
|
|
1598
|
+
browserSessionId,
|
|
1599
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1600
|
+
});
|
|
1601
|
+
if (!admitted) {
|
|
1602
|
+
throw new BrowserSessionStateError("BrowserSession controller authority changed");
|
|
1603
|
+
}
|
|
1604
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
1605
|
+
return await withBrowserPlacement(
|
|
1606
|
+
sourceSession,
|
|
1607
|
+
grant,
|
|
1608
|
+
record.session.placement,
|
|
1609
|
+
binding.placementInstanceId,
|
|
1610
|
+
channelOperation,
|
|
1611
|
+
context.req.raw.signal,
|
|
1612
|
+
async (placement) => {
|
|
1613
|
+
const client = await provisionController(
|
|
1614
|
+
deps,
|
|
1615
|
+
grant,
|
|
1616
|
+
record,
|
|
1617
|
+
placement,
|
|
1618
|
+
requestOrigin(context, deps.settings.corsAllowOriginRegex),
|
|
1619
|
+
);
|
|
1620
|
+
const tokens = deriveBrowserSessionControllerTokens({
|
|
1621
|
+
rootSecret: browserAuthorityRoot(deps),
|
|
1622
|
+
accountId: grant.accountId,
|
|
1623
|
+
workspaceId,
|
|
1624
|
+
placement: record.session.placement,
|
|
1625
|
+
placementInstanceId: placement.placementInstanceId,
|
|
1626
|
+
browserSessionId,
|
|
1627
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1628
|
+
tokenGeneration: record.tokenGeneration,
|
|
1629
|
+
});
|
|
1630
|
+
const result = await callback({
|
|
1631
|
+
client,
|
|
1632
|
+
sessionClient: client.sessionClient({
|
|
1633
|
+
reference: {
|
|
1634
|
+
browserSessionId,
|
|
1635
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1636
|
+
},
|
|
1637
|
+
...tokens,
|
|
1638
|
+
}),
|
|
1639
|
+
record,
|
|
1640
|
+
binding,
|
|
1641
|
+
placement,
|
|
1642
|
+
});
|
|
1643
|
+
await touchBrowserSessionController(deps.db, {
|
|
1644
|
+
accountId: grant.accountId,
|
|
1645
|
+
workspaceId,
|
|
1646
|
+
browserSessionId,
|
|
1647
|
+
controllerGeneration: binding.controllerGeneration,
|
|
1648
|
+
}).catch(() => false);
|
|
1649
|
+
return result;
|
|
1650
|
+
},
|
|
1651
|
+
);
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
throw browserRouteError(error);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async function finishSuspendedBrowserControllerCleanup(
|
|
1658
|
+
context: Context,
|
|
1659
|
+
grant: AccessGrant,
|
|
1660
|
+
workspaceId: string,
|
|
1661
|
+
browserSessionId: string,
|
|
1662
|
+
): Promise<void> {
|
|
1663
|
+
const record = await getBrowserSessionControlRecord(deps.db, {
|
|
1664
|
+
accountId: grant.accountId,
|
|
1665
|
+
workspaceId,
|
|
1666
|
+
browserSessionId,
|
|
1667
|
+
});
|
|
1668
|
+
if (record.session.lifecycle !== "suspended") {
|
|
1669
|
+
throw new BrowserSessionStateError("BrowserSession is not suspended");
|
|
1670
|
+
}
|
|
1671
|
+
const binding = record.session.controller;
|
|
1672
|
+
if (binding) {
|
|
1673
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
1674
|
+
await withBrowserPlacement(
|
|
1675
|
+
sourceSession,
|
|
1676
|
+
grant,
|
|
1677
|
+
record.session.placement,
|
|
1678
|
+
binding.placementInstanceId,
|
|
1679
|
+
"browser.suspend",
|
|
1680
|
+
context.req.raw.signal,
|
|
1681
|
+
async (placement) => {
|
|
1682
|
+
const client = await provisionController(
|
|
1683
|
+
deps,
|
|
1684
|
+
grant,
|
|
1685
|
+
record,
|
|
1686
|
+
placement,
|
|
1687
|
+
requestOrigin(context, deps.settings.corsAllowOriginRegex),
|
|
1688
|
+
);
|
|
1689
|
+
await endCapturedBrowserController(client, browserSessionId, binding);
|
|
1690
|
+
await clearSuspendedBrowserSessionController(deps.db, {
|
|
1691
|
+
accountId: grant.accountId,
|
|
1692
|
+
workspaceId,
|
|
1693
|
+
browserSessionId,
|
|
1694
|
+
expectedControllerGeneration: binding.controllerGeneration,
|
|
1695
|
+
});
|
|
1696
|
+
},
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
await releaseInteractionHolder(
|
|
1700
|
+
deps,
|
|
1701
|
+
grant,
|
|
1702
|
+
workspaceId,
|
|
1703
|
+
browserSessionId,
|
|
1704
|
+
record.session.placement,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function browserCreateInput(
|
|
1710
|
+
grant: AccessGrant,
|
|
1711
|
+
workspaceId: string,
|
|
1712
|
+
request: CreateBrowserSessionRequestValue,
|
|
1713
|
+
placement: InteractionPlacement,
|
|
1714
|
+
) {
|
|
1715
|
+
const attached = placement.kind === "attached_device";
|
|
1716
|
+
return {
|
|
1717
|
+
accountId: grant.accountId,
|
|
1718
|
+
workspaceId,
|
|
1719
|
+
operationId: request.operationId,
|
|
1720
|
+
associatedSessionId: request.sessionId,
|
|
1721
|
+
actorSubjectId: grant.subjectId,
|
|
1722
|
+
name: request.name ?? "Browser",
|
|
1723
|
+
initialUrl: request.initialUrl ?? null,
|
|
1724
|
+
placement,
|
|
1725
|
+
driverId: BROWSER_DRIVER_ID,
|
|
1726
|
+
engine: attached ? ("chrome" as const) : ("chromium" as const),
|
|
1727
|
+
headless: attached ? false : request.headless,
|
|
1728
|
+
identityId: request.identityId ?? null,
|
|
1729
|
+
baseRevisionId: request.baseRevisionId ?? null,
|
|
1730
|
+
linkedComputerSessionId: request.linkedComputerSessionId ?? null,
|
|
1731
|
+
resolveDefaultRevision:
|
|
1732
|
+
!attached && request.identityId !== undefined && request.baseRevisionId === undefined,
|
|
1733
|
+
...(attached ? { capabilities: ATTACHED_BROWSER_SESSION_CAPABILITIES } : {}),
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function assertCreateReplay(
|
|
1738
|
+
request: CreateBrowserSessionRequestValue,
|
|
1739
|
+
session: BrowserSessionValue,
|
|
1740
|
+
): void {
|
|
1741
|
+
if (request.placement && !sameInteractionPlacement(request.placement, session.placement)) {
|
|
1742
|
+
throw new BrowserSessionOperationConflictError(
|
|
1743
|
+
"BrowserSession create operation is bound to another placement",
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
if ((request.identityId ?? null) !== session.identityId) {
|
|
1747
|
+
throw new BrowserSessionOperationConflictError(
|
|
1748
|
+
"BrowserSession create operation is bound to another identity",
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
if (request.baseRevisionId !== undefined && request.baseRevisionId !== session.baseRevisionId) {
|
|
1752
|
+
throw new BrowserSessionOperationConflictError(
|
|
1753
|
+
"BrowserSession create operation is bound to another revision",
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
if ((request.linkedComputerSessionId ?? null) !== session.linkedComputerSessionId) {
|
|
1757
|
+
throw new BrowserSessionOperationConflictError(
|
|
1758
|
+
"BrowserSession create operation is bound to another ComputerSession",
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
async function requireLinkedComputerBinding(
|
|
1764
|
+
deps: ApiRouteDeps,
|
|
1765
|
+
grant: AccessGrant,
|
|
1766
|
+
browser: BrowserSessionValue,
|
|
1767
|
+
placement: BrowserPlacement,
|
|
1768
|
+
): Promise<{ computerSessionId: string; controllerGeneration: string } | null> {
|
|
1769
|
+
if (!browser.linkedComputerSessionId) return null;
|
|
1770
|
+
const record = await getComputerSessionControlRecord(deps.db, {
|
|
1771
|
+
accountId: grant.accountId,
|
|
1772
|
+
workspaceId: browser.workspaceId,
|
|
1773
|
+
computerSessionId: browser.linkedComputerSessionId,
|
|
1774
|
+
});
|
|
1775
|
+
if (
|
|
1776
|
+
record.session.lifecycle !== "active" ||
|
|
1777
|
+
!record.session.controller ||
|
|
1778
|
+
!sameInteractionPlacement(record.session.placement, browser.placement) ||
|
|
1779
|
+
record.session.controller.placementInstanceId !== placement.placementInstanceId
|
|
1780
|
+
) {
|
|
1781
|
+
throw new BrowserSessionStateError(
|
|
1782
|
+
"Linked ComputerSession is not active on the browser placement",
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
return {
|
|
1786
|
+
computerSessionId: record.session.id,
|
|
1787
|
+
controllerGeneration: record.session.controller.controllerGeneration,
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function requireBrowserRevisionRestoreAuthority(
|
|
1792
|
+
deps: ApiRouteDeps,
|
|
1793
|
+
grant: AccessGrant,
|
|
1794
|
+
session: BrowserSessionValue,
|
|
1795
|
+
): Promise<BrowserRevisionArtifactAuthority> {
|
|
1796
|
+
if (!session.identityId || !session.baseRevisionId) {
|
|
1797
|
+
throw new BrowserSessionStateError("BrowserSession has no revision recovery authority");
|
|
1798
|
+
}
|
|
1799
|
+
return await getBrowserRevisionArtifactAuthority(deps.db, {
|
|
1800
|
+
accountId: grant.accountId,
|
|
1801
|
+
workspaceId: session.workspaceId,
|
|
1802
|
+
identityId: session.identityId,
|
|
1803
|
+
revisionId: session.baseRevisionId,
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
async function prepareBrowserStateRestore(
|
|
1808
|
+
deps: ApiRouteDeps,
|
|
1809
|
+
grant: AccessGrant,
|
|
1810
|
+
session: BrowserSessionValue,
|
|
1811
|
+
placement: InteractionPlacement,
|
|
1812
|
+
authority: BrowserRevisionArtifactAuthority | null,
|
|
1813
|
+
): Promise<RestorePlacementBrowserStateInput | null> {
|
|
1814
|
+
if (!session.baseRevisionId) {
|
|
1815
|
+
if (authority) {
|
|
1816
|
+
throw new BrowserIdentityStateError("BrowserSession has unexpected revision authority");
|
|
1817
|
+
}
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1820
|
+
if (
|
|
1821
|
+
!session.identityId ||
|
|
1822
|
+
!authority ||
|
|
1823
|
+
authority.revision.id !== session.baseRevisionId ||
|
|
1824
|
+
authority.revision.identityId !== session.identityId
|
|
1825
|
+
) {
|
|
1826
|
+
throw new BrowserIdentityStateError("BrowserSession revision authority is inconsistent");
|
|
1827
|
+
}
|
|
1828
|
+
if (
|
|
1829
|
+
authority.artifacts.length !== 1 ||
|
|
1830
|
+
authority.revision.components.length !== 1 ||
|
|
1831
|
+
authority.revision.components[0]?.kind !== "chromium_profile"
|
|
1832
|
+
) {
|
|
1833
|
+
throw new BrowserIdentityStateError(
|
|
1834
|
+
"BrowserRevision does not have one restorable Chromium profile",
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
const artifact = authority.artifacts[0]!;
|
|
1838
|
+
if (artifact.manifestDigest !== authority.revision.manifestDigest) {
|
|
1839
|
+
throw new BrowserIdentityStateError("BrowserRevision manifest authority is inconsistent");
|
|
1840
|
+
}
|
|
1841
|
+
return await prepareBrowserArtifactRestore(
|
|
1842
|
+
deps,
|
|
1843
|
+
grant,
|
|
1844
|
+
session,
|
|
1845
|
+
placement,
|
|
1846
|
+
artifact,
|
|
1847
|
+
authority.revision.manifestDigest,
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
async function prepareBrowserPrivateCheckpointRestore(
|
|
1852
|
+
deps: ApiRouteDeps,
|
|
1853
|
+
grant: AccessGrant,
|
|
1854
|
+
session: BrowserSessionValue,
|
|
1855
|
+
authority: BrowserPrivateCheckpointAuthority,
|
|
1856
|
+
): Promise<RestorePlacementBrowserStateInput> {
|
|
1857
|
+
if (authority.sourceBrowserSessionId !== session.id) {
|
|
1858
|
+
throw new BrowserSessionStateError("BrowserSession private checkpoint belongs elsewhere");
|
|
1859
|
+
}
|
|
1860
|
+
return await prepareBrowserArtifactRestore(
|
|
1861
|
+
deps,
|
|
1862
|
+
grant,
|
|
1863
|
+
session,
|
|
1864
|
+
session.placement,
|
|
1865
|
+
authority,
|
|
1866
|
+
authority.manifestDigest,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
async function prepareBrowserArtifactRestore(
|
|
1871
|
+
deps: ApiRouteDeps,
|
|
1872
|
+
grant: AccessGrant,
|
|
1873
|
+
session: BrowserSessionValue,
|
|
1874
|
+
placement: InteractionPlacement,
|
|
1875
|
+
artifact: {
|
|
1876
|
+
objectKey: string;
|
|
1877
|
+
encryptedDataKey: string;
|
|
1878
|
+
format: string;
|
|
1879
|
+
artifactDigest: string;
|
|
1880
|
+
contentDigest: string;
|
|
1881
|
+
sizeBytes: number;
|
|
1882
|
+
materialization: BrowserRevisionMaterializationValue;
|
|
1883
|
+
},
|
|
1884
|
+
manifestDigest: string,
|
|
1885
|
+
): Promise<RestorePlacementBrowserStateInput> {
|
|
1886
|
+
if (
|
|
1887
|
+
artifact.format !== BROWSER_PROFILE_ARTIFACT_FORMAT ||
|
|
1888
|
+
artifact.materialization.engine !== "chromium" ||
|
|
1889
|
+
artifact.materialization.driverId !== BROWSER_DRIVER_ID ||
|
|
1890
|
+
artifact.materialization.driverSchemaVersion !== 1
|
|
1891
|
+
) {
|
|
1892
|
+
throw new BrowserIdentityStateError("Browser state requires another browser driver");
|
|
1893
|
+
}
|
|
1894
|
+
assertMaterializationPlacement(artifact.materialization, placement);
|
|
1895
|
+
const objectStorage = deps.objectStorage;
|
|
1896
|
+
if (!objectStorage) {
|
|
1897
|
+
throw new HTTPException(503, {
|
|
1898
|
+
message: "browser state storage is not configured",
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
if (artifact.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
|
|
1902
|
+
throw new BrowserIdentityStateError("Browser state artifact exceeds the supported byte limit");
|
|
1903
|
+
}
|
|
1904
|
+
const rootKey = requireBrowserStateRoot(deps);
|
|
1905
|
+
let dataKey: Buffer;
|
|
1906
|
+
try {
|
|
1907
|
+
dataKey = unwrapBrowserStateDataKey(rootKey, artifact.encryptedDataKey, {
|
|
1908
|
+
accountId: grant.accountId,
|
|
1909
|
+
workspaceId: session.workspaceId,
|
|
1910
|
+
objectKey: artifact.objectKey,
|
|
1911
|
+
artifactDigest: artifact.artifactDigest,
|
|
1912
|
+
contentDigest: artifact.contentDigest,
|
|
1913
|
+
});
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
throw new BrowserIdentityStateError("Browser state encryption authority is unavailable", {
|
|
1916
|
+
cause: error,
|
|
1917
|
+
});
|
|
1918
|
+
} finally {
|
|
1919
|
+
rootKey.fill(0);
|
|
1920
|
+
}
|
|
1921
|
+
const aad = browserStateArtifactAad({
|
|
1922
|
+
accountId: grant.accountId,
|
|
1923
|
+
workspaceId: session.workspaceId,
|
|
1924
|
+
objectKey: artifact.objectKey,
|
|
1925
|
+
});
|
|
1926
|
+
try {
|
|
1927
|
+
const signed = await objectStorage.createGetUrl({
|
|
1928
|
+
key: artifact.objectKey,
|
|
1929
|
+
});
|
|
1930
|
+
return {
|
|
1931
|
+
objectKey: artifact.objectKey,
|
|
1932
|
+
format: BROWSER_PROFILE_ARTIFACT_FORMAT,
|
|
1933
|
+
artifactDigest: artifact.artifactDigest,
|
|
1934
|
+
contentDigest: artifact.contentDigest,
|
|
1935
|
+
manifestDigest,
|
|
1936
|
+
sizeBytes: artifact.sizeBytes,
|
|
1937
|
+
dataKey,
|
|
1938
|
+
aad,
|
|
1939
|
+
materialization: artifact.materialization,
|
|
1940
|
+
download: {
|
|
1941
|
+
url: signed.url,
|
|
1942
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
1943
|
+
},
|
|
1944
|
+
};
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
dataKey.fill(0);
|
|
1947
|
+
aad.fill(0);
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function assertMaterializationPlacement(
|
|
1953
|
+
materialization: BrowserRevisionMaterializationValue,
|
|
1954
|
+
placement: InteractionPlacement,
|
|
1955
|
+
): void {
|
|
1956
|
+
if (
|
|
1957
|
+
materialization.portability === "placement_bound" &&
|
|
1958
|
+
(!materialization.placement || !sameInteractionPlacement(materialization.placement, placement))
|
|
1959
|
+
) {
|
|
1960
|
+
throw new BrowserIdentityStateError("BrowserRevision is bound to another placement");
|
|
1961
|
+
}
|
|
1962
|
+
if (
|
|
1963
|
+
materialization.portability === "provider_bound" &&
|
|
1964
|
+
(placement.kind !== "external_provider" || materialization.providerId !== placement.providerId)
|
|
1965
|
+
) {
|
|
1966
|
+
throw new BrowserIdentityStateError("BrowserRevision is bound to another browser provider");
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function sameInteractionPlacement(
|
|
1971
|
+
left: InteractionPlacement,
|
|
1972
|
+
right: InteractionPlacement,
|
|
1973
|
+
): boolean {
|
|
1974
|
+
if (left.kind !== right.kind) return false;
|
|
1975
|
+
switch (left.kind) {
|
|
1976
|
+
case "sandbox_group":
|
|
1977
|
+
return right.kind === "sandbox_group" && left.sandboxGroupId === right.sandboxGroupId;
|
|
1978
|
+
case "connected_machine":
|
|
1979
|
+
return right.kind === "connected_machine" && left.sandboxId === right.sandboxId;
|
|
1980
|
+
case "attached_device":
|
|
1981
|
+
return right.kind === "attached_device" && left.deviceId === right.deviceId;
|
|
1982
|
+
case "external_provider":
|
|
1983
|
+
return (
|
|
1984
|
+
right.kind === "external_provider" &&
|
|
1985
|
+
left.providerId === right.providerId &&
|
|
1986
|
+
left.placementId === right.placementId
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
async function ensureInteractionHolder(
|
|
1992
|
+
deps: ApiRouteDeps,
|
|
1993
|
+
grant: AccessGrant,
|
|
1994
|
+
sourceSession: Session,
|
|
1995
|
+
browserSessionId: string,
|
|
1996
|
+
placement: BrowserPlacement,
|
|
1997
|
+
waitSignal: AbortSignal,
|
|
1998
|
+
): Promise<boolean> {
|
|
1999
|
+
if (placement.placement.kind !== "sandbox_group") return false;
|
|
2000
|
+
if (!placement.lease?.instanceId) {
|
|
2001
|
+
throw new BrowserSessionStateError("BrowserSession lease placement is unavailable");
|
|
2002
|
+
}
|
|
2003
|
+
const acquired = await acquireLease(deps.db, {
|
|
2004
|
+
accountId: grant.accountId,
|
|
2005
|
+
workspaceId: sourceSession.workspaceId,
|
|
2006
|
+
sandboxGroupId: placement.placement.sandboxGroupId,
|
|
2007
|
+
kind: "interaction",
|
|
2008
|
+
holderId: interactionHolderId(browserSessionId),
|
|
2009
|
+
subjectId: sourceSession.id,
|
|
2010
|
+
backend: placement.lease.backend,
|
|
2011
|
+
os: placement.lease.os,
|
|
2012
|
+
image: placement.lease.image,
|
|
2013
|
+
rigVersionId: placement.lease.rigVersionId,
|
|
2014
|
+
leaseTtlMs: deps.settings.sandboxLeaseTtlMs,
|
|
2015
|
+
expectedEpoch: placement.lease.leaseEpoch,
|
|
2016
|
+
waitSignal,
|
|
2017
|
+
});
|
|
2018
|
+
if (acquired.role === "blocked" || acquired.role === "fenced") {
|
|
2019
|
+
throw new BrowserSessionStateError("BrowserSession placement is transitioning; retry");
|
|
2020
|
+
}
|
|
2021
|
+
if (
|
|
2022
|
+
acquired.role === "spawner" ||
|
|
2023
|
+
acquired.lease.leaseEpoch !== placement.lease.leaseEpoch ||
|
|
2024
|
+
acquired.lease.instanceId !== placement.placementInstanceId
|
|
2025
|
+
) {
|
|
2026
|
+
await releaseInteractionHolder(
|
|
2027
|
+
deps,
|
|
2028
|
+
grant,
|
|
2029
|
+
sourceSession.workspaceId,
|
|
2030
|
+
browserSessionId,
|
|
2031
|
+
placement.placement,
|
|
2032
|
+
).catch(() => undefined);
|
|
2033
|
+
throw new BrowserSessionStateError("BrowserSession placement fence changed; retry");
|
|
2034
|
+
}
|
|
2035
|
+
return true;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
async function ensureDispatchedGeneration(
|
|
2039
|
+
deps: ApiRouteDeps,
|
|
2040
|
+
grant: AccessGrant,
|
|
2041
|
+
workspaceId: string,
|
|
2042
|
+
browserSessionId: string,
|
|
2043
|
+
operationId: string,
|
|
2044
|
+
placementInstanceId: string,
|
|
2045
|
+
): Promise<BrowserSessionControlRecord> {
|
|
2046
|
+
let record = await getBrowserSessionControlRecord(deps.db, {
|
|
2047
|
+
accountId: grant.accountId,
|
|
2048
|
+
workspaceId,
|
|
2049
|
+
browserSessionId,
|
|
2050
|
+
operationId,
|
|
2051
|
+
});
|
|
2052
|
+
if (record.operation?.state === "prepared") {
|
|
2053
|
+
const controllerGeneration = randomUUID();
|
|
2054
|
+
try {
|
|
2055
|
+
await dispatchBrowserSessionOperation(deps.db, {
|
|
2056
|
+
accountId: grant.accountId,
|
|
2057
|
+
workspaceId,
|
|
2058
|
+
operationId,
|
|
2059
|
+
browserSessionId,
|
|
2060
|
+
controllerGeneration,
|
|
2061
|
+
controller: {
|
|
2062
|
+
controllerId: "opengeni-browserd",
|
|
2063
|
+
controllerGeneration,
|
|
2064
|
+
placementInstanceId,
|
|
2065
|
+
},
|
|
2066
|
+
});
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if (!(error instanceof BrowserSessionOperationConflictError)) throw error;
|
|
2069
|
+
}
|
|
2070
|
+
record = await getBrowserSessionControlRecord(deps.db, {
|
|
2071
|
+
accountId: grant.accountId,
|
|
2072
|
+
workspaceId,
|
|
2073
|
+
browserSessionId,
|
|
2074
|
+
operationId,
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
if (record.operation?.state !== "dispatched") {
|
|
2078
|
+
throw new BrowserSessionStateError("BrowserSession create operation is not dispatchable");
|
|
2079
|
+
}
|
|
2080
|
+
if (
|
|
2081
|
+
!record.session.controller ||
|
|
2082
|
+
record.session.controller.controllerGeneration !== record.operation.controllerGeneration ||
|
|
2083
|
+
record.session.controller.placementInstanceId !== placementInstanceId
|
|
2084
|
+
) {
|
|
2085
|
+
throw new BrowserSessionOperationConflictError(
|
|
2086
|
+
"BrowserSession dispatch controller binding is inconsistent",
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
return record;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function requireOperationGeneration(record: BrowserSessionControlRecord): string {
|
|
2093
|
+
const generation = record.operation?.controllerGeneration;
|
|
2094
|
+
if (!generation) throw new BrowserSessionStateError("BrowserSession controller fence is absent");
|
|
2095
|
+
return generation;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
async function provisionController(
|
|
2099
|
+
deps: ApiRouteDeps,
|
|
2100
|
+
grant: AccessGrant,
|
|
2101
|
+
record: BrowserSessionControlRecord,
|
|
2102
|
+
placement: BrowserPlacement,
|
|
2103
|
+
origin: string | null,
|
|
2104
|
+
): Promise<BrowserControlClient> {
|
|
2105
|
+
const rootSecret = browserAuthorityRoot(deps);
|
|
2106
|
+
const adminToken = deriveBrowserControllerAdminToken({
|
|
2107
|
+
rootSecret,
|
|
2108
|
+
accountId: grant.accountId,
|
|
2109
|
+
workspaceId: record.session.workspaceId,
|
|
2110
|
+
placement: record.session.placement,
|
|
2111
|
+
placementInstanceId: placement.placementInstanceId,
|
|
2112
|
+
});
|
|
2113
|
+
return (
|
|
2114
|
+
await provisionBrowserControlClient(placement.session, {
|
|
2115
|
+
adminToken,
|
|
2116
|
+
nativeAuthority: nativeBrowserControllerAuthority(record.session.workspaceId, placement),
|
|
2117
|
+
...(origin ? { allowedOrigins: [origin] } : {}),
|
|
2118
|
+
})
|
|
2119
|
+
).client;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function nativeBrowserControllerAuthority(
|
|
2123
|
+
workspaceId: string,
|
|
2124
|
+
placement: BrowserPlacement,
|
|
2125
|
+
): { scopeId: string; scopeGeneration: string } {
|
|
2126
|
+
const placementId = (() => {
|
|
2127
|
+
switch (placement.placement.kind) {
|
|
2128
|
+
case "attached_device":
|
|
2129
|
+
return placement.placement.deviceId;
|
|
2130
|
+
case "connected_machine":
|
|
2131
|
+
return placement.placement.sandboxId;
|
|
2132
|
+
case "sandbox_group":
|
|
2133
|
+
return placement.placement.sandboxGroupId;
|
|
2134
|
+
case "external_provider":
|
|
2135
|
+
return `${placement.placement.providerId}:${placement.placement.placementId}`;
|
|
2136
|
+
}
|
|
2137
|
+
})();
|
|
2138
|
+
return {
|
|
2139
|
+
scopeId: `${workspaceId}:${placement.placement.kind}:${placementId}`,
|
|
2140
|
+
scopeGeneration: placement.placementInstanceId,
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
async function releaseInteractionHolder(
|
|
2145
|
+
deps: ApiRouteDeps,
|
|
2146
|
+
grant: AccessGrant,
|
|
2147
|
+
workspaceId: string,
|
|
2148
|
+
browserSessionId: string,
|
|
2149
|
+
placement: InteractionPlacement,
|
|
2150
|
+
): Promise<void> {
|
|
2151
|
+
if (placement.kind !== "sandbox_group") return;
|
|
2152
|
+
await releaseLeaseHolder(deps.db, {
|
|
2153
|
+
accountId: grant.accountId,
|
|
2154
|
+
workspaceId,
|
|
2155
|
+
sandboxGroupId: placement.sandboxGroupId,
|
|
2156
|
+
kind: "interaction",
|
|
2157
|
+
holderId: interactionHolderId(browserSessionId),
|
|
2158
|
+
idleGraceMs: deps.settings.sandboxIdleGraceMs,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function interactionHolderId(browserSessionId: string): string {
|
|
2163
|
+
return `browser-session:${browserSessionId}`;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function browserAuthorityRoot(deps: ApiRouteDeps): string {
|
|
2167
|
+
const root = resolveFirstPartyDelegationSecret(deps.settings);
|
|
2168
|
+
if (!root) {
|
|
2169
|
+
throw new HTTPException(503, {
|
|
2170
|
+
message: "browser controller authority is not configured",
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
return root;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function requireBrowserStateRoot(deps: ApiRouteDeps): Buffer {
|
|
2177
|
+
const configured = environmentsEncryptionKeyBytes(deps.settings);
|
|
2178
|
+
if (!configured) {
|
|
2179
|
+
throw new HTTPException(503, {
|
|
2180
|
+
message: "browser state encryption is not configured",
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
const root = Buffer.from(configured);
|
|
2184
|
+
configured.fill(0);
|
|
2185
|
+
return root;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function browserRevisionMaterialization(
|
|
2189
|
+
manifest: PlacementBrowserStateCaptureReceipt["manifest"],
|
|
2190
|
+
placement: InteractionPlacement,
|
|
2191
|
+
): BrowserRevisionMaterializationValue {
|
|
2192
|
+
const portable = manifest.profileCrypto !== "platform_bound";
|
|
2193
|
+
return {
|
|
2194
|
+
portability: portable ? "portable" : "placement_bound",
|
|
2195
|
+
reason: portable
|
|
2196
|
+
? null
|
|
2197
|
+
: "Profile encryption depends on the source operating-system credential store.",
|
|
2198
|
+
platform: manifest.platform,
|
|
2199
|
+
architecture: manifest.architecture,
|
|
2200
|
+
engine: manifest.engine,
|
|
2201
|
+
engineVersion: manifest.engineVersion,
|
|
2202
|
+
driverId: manifest.driverId,
|
|
2203
|
+
driverSchemaVersion: manifest.driverSchemaVersion,
|
|
2204
|
+
profileCrypto: manifest.profileCrypto,
|
|
2205
|
+
providerId: null,
|
|
2206
|
+
placement: portable ? null : placement,
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
async function endCapturedBrowserController(
|
|
2211
|
+
client: BrowserControlClient,
|
|
2212
|
+
browserSessionId: string,
|
|
2213
|
+
binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>,
|
|
2214
|
+
): Promise<void> {
|
|
2215
|
+
try {
|
|
2216
|
+
await client.endSession(
|
|
2217
|
+
{
|
|
2218
|
+
browserSessionId,
|
|
2219
|
+
controllerGeneration: binding.controllerGeneration,
|
|
2220
|
+
},
|
|
2221
|
+
{ removeState: true },
|
|
2222
|
+
);
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (!(error instanceof BrowserControlRequestError && error.status === 404)) throw error;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function isDefiniteBrowserControllerFailure(error: unknown): boolean {
|
|
2229
|
+
return (
|
|
2230
|
+
error instanceof BrowserControlRequestError &&
|
|
2231
|
+
!error.error.retryable &&
|
|
2232
|
+
error.error.code !== "outcome_unknown"
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
async function settleBrowserSessionSuspensionCaptureFailure(
|
|
2237
|
+
deps: ApiRouteDeps,
|
|
2238
|
+
input: {
|
|
2239
|
+
accountId: string;
|
|
2240
|
+
workspaceId: string;
|
|
2241
|
+
operationId: string;
|
|
2242
|
+
browserSessionId: string;
|
|
2243
|
+
controllerGeneration: string;
|
|
2244
|
+
},
|
|
2245
|
+
error: unknown,
|
|
2246
|
+
) {
|
|
2247
|
+
if (!(error instanceof BrowserControlRequestError)) return null;
|
|
2248
|
+
const outcomeUnknown = error.error.code === "outcome_unknown";
|
|
2249
|
+
if (!outcomeUnknown && error.error.retryable) return null;
|
|
2250
|
+
return await failBrowserSessionSuspension(deps.db, {
|
|
2251
|
+
...input,
|
|
2252
|
+
...(outcomeUnknown ? { state: "outcome_unknown" as const } : {}),
|
|
2253
|
+
error: error.error,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function settleBrowserRevisionCaptureFailure(
|
|
2258
|
+
deps: ApiRouteDeps,
|
|
2259
|
+
input: {
|
|
2260
|
+
accountId: string;
|
|
2261
|
+
workspaceId: string;
|
|
2262
|
+
operationId: string;
|
|
2263
|
+
browserSessionId: string;
|
|
2264
|
+
controllerGeneration: string;
|
|
2265
|
+
},
|
|
2266
|
+
error: unknown,
|
|
2267
|
+
): Promise<void> {
|
|
2268
|
+
if (!(error instanceof BrowserControlRequestError)) return;
|
|
2269
|
+
const outcomeUnknown = error.error.code === "outcome_unknown";
|
|
2270
|
+
if (!outcomeUnknown && error.error.retryable) return;
|
|
2271
|
+
await failBrowserRevisionPublication(deps.db, {
|
|
2272
|
+
...input,
|
|
2273
|
+
state: outcomeUnknown ? "outcome_unknown" : "failed",
|
|
2274
|
+
error: error.error,
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
async function requireSourceSession(
|
|
2279
|
+
deps: ApiRouteDeps,
|
|
2280
|
+
workspaceId: string,
|
|
2281
|
+
sessionId: string,
|
|
2282
|
+
): Promise<Session> {
|
|
2283
|
+
const session = await getSession(deps.db, workspaceId, sessionId);
|
|
2284
|
+
if (!session) throw new BrowserSessionNotFoundError("Associated session not found");
|
|
2285
|
+
return session;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
async function authorizeSourceSession(
|
|
2289
|
+
deps: ApiRouteDeps,
|
|
2290
|
+
grant: AccessGrant,
|
|
2291
|
+
sessionId: string,
|
|
2292
|
+
operation: SessionAuthorizationOperation,
|
|
2293
|
+
): Promise<void> {
|
|
2294
|
+
try {
|
|
2295
|
+
await requireSessionAuthorization(deps, grant, {
|
|
2296
|
+
sessionId,
|
|
2297
|
+
operation,
|
|
2298
|
+
surface: "http",
|
|
2299
|
+
});
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
if (error instanceof SessionAuthorizationDeniedError) {
|
|
2302
|
+
throw new HTTPException(404, {
|
|
2303
|
+
message: "session not found",
|
|
2304
|
+
cause: error,
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
2308
|
+
throw new HTTPException(503, {
|
|
2309
|
+
message: "session authorization is unavailable",
|
|
2310
|
+
cause: error,
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
throw error;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
async function parseJsonBody<T>(
|
|
2318
|
+
context: Context,
|
|
2319
|
+
schema: {
|
|
2320
|
+
safeParse(value: unknown): { success: true; data: T } | { success: false };
|
|
2321
|
+
},
|
|
2322
|
+
): Promise<T> {
|
|
2323
|
+
const value = await context.req.json().catch(() => undefined);
|
|
2324
|
+
const parsed = schema.safeParse(value);
|
|
2325
|
+
if (!parsed.success) throw new HTTPException(400, { message: "invalid request body" });
|
|
2326
|
+
return parsed.data;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async function parseEmptyJsonBody(context: Context): Promise<void> {
|
|
2330
|
+
const text = await context.req.text();
|
|
2331
|
+
if (!text.trim()) return;
|
|
2332
|
+
try {
|
|
2333
|
+
const value = JSON.parse(text) as unknown;
|
|
2334
|
+
if (
|
|
2335
|
+
typeof value !== "object" ||
|
|
2336
|
+
value === null ||
|
|
2337
|
+
Array.isArray(value) ||
|
|
2338
|
+
Object.keys(value).length !== 0
|
|
2339
|
+
) {
|
|
2340
|
+
throw new Error("not empty");
|
|
2341
|
+
}
|
|
2342
|
+
} catch {
|
|
2343
|
+
throw new HTTPException(400, { message: "invalid request body" });
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function requireUuidParam(context: Context, name: string): string {
|
|
2348
|
+
const value = context.req.param(name) ?? "";
|
|
2349
|
+
if (!isUuid(value)) throw new HTTPException(404, { message: "BrowserSession not found" });
|
|
2350
|
+
return value;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function requireOpaqueParam(context: Context, name: string): string {
|
|
2354
|
+
const value = context.req.param(name) ?? "";
|
|
2355
|
+
if (value.length < 1 || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
2356
|
+
throw new HTTPException(404, { message: "browser target not found" });
|
|
2357
|
+
}
|
|
2358
|
+
return value;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function diagnosticKinds(
|
|
2362
|
+
value: string | undefined,
|
|
2363
|
+
): Array<ReturnType<typeof BrowserDiagnosticKind.parse>> | null {
|
|
2364
|
+
if (value === undefined || value === "") return null;
|
|
2365
|
+
const raw = [...new Set(value.split(","))];
|
|
2366
|
+
if (raw.length > 4 || raw.some((entry) => !entry)) {
|
|
2367
|
+
throw new HTTPException(400, { message: "invalid diagnostic kinds" });
|
|
2368
|
+
}
|
|
2369
|
+
const parsed = raw.map((entry) => BrowserDiagnosticKind.safeParse(entry));
|
|
2370
|
+
if (parsed.some((entry) => !entry.success)) {
|
|
2371
|
+
throw new HTTPException(400, { message: "invalid diagnostic kinds" });
|
|
2372
|
+
}
|
|
2373
|
+
return parsed.map((entry) => entry.data!);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function optionalBoundedInteger(
|
|
2377
|
+
value: string | undefined,
|
|
2378
|
+
minimum: number,
|
|
2379
|
+
maximum: number,
|
|
2380
|
+
): number | null {
|
|
2381
|
+
if (value === undefined || value === "") return null;
|
|
2382
|
+
if (!/^\d+$/u.test(value)) throw new HTTPException(400, { message: "invalid integer query" });
|
|
2383
|
+
const parsed = Number(value);
|
|
2384
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
2385
|
+
throw new HTTPException(400, { message: "integer query is out of range" });
|
|
2386
|
+
}
|
|
2387
|
+
return parsed;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function isUuid(value: unknown): value is string {
|
|
2391
|
+
return (
|
|
2392
|
+
typeof value === "string" &&
|
|
2393
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function requestOrigin(context: Context, allowedPattern: string): string | null {
|
|
2398
|
+
return validateBrowserRequestOrigin(context.req.header("origin"), allowedPattern);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
export function validateBrowserRequestOrigin(
|
|
2402
|
+
value: string | undefined,
|
|
2403
|
+
allowedPattern: string,
|
|
2404
|
+
): string | null {
|
|
2405
|
+
if (!value) return null;
|
|
2406
|
+
let url: URL;
|
|
2407
|
+
try {
|
|
2408
|
+
url = new URL(value);
|
|
2409
|
+
} catch {
|
|
2410
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
2411
|
+
}
|
|
2412
|
+
if (
|
|
2413
|
+
url.origin === "null" ||
|
|
2414
|
+
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
2415
|
+
url.origin !== value
|
|
2416
|
+
) {
|
|
2417
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
2418
|
+
}
|
|
2419
|
+
if (!allowedCorsOrigin(allowedPattern, url.origin)) {
|
|
2420
|
+
throw new HTTPException(403, { message: "request origin is not allowed" });
|
|
2421
|
+
}
|
|
2422
|
+
return url.origin;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
export function interactionActorForGrant(
|
|
2426
|
+
grant: AccessGrant,
|
|
2427
|
+
): ReturnType<typeof InteractionActor.parse> {
|
|
2428
|
+
if (grant.principalKind !== "agent_attempt") {
|
|
2429
|
+
return InteractionActor.parse({
|
|
2430
|
+
kind: grant.principalKind === "service" ? "system" : "human",
|
|
2431
|
+
subjectId: grant.subjectId,
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
return InteractionActor.parse({
|
|
2435
|
+
kind: "agent",
|
|
2436
|
+
subjectId: grant.subjectId,
|
|
2437
|
+
sessionId: grant.metadata?.["sessionId"],
|
|
2438
|
+
turnId: grant.metadata?.["turnId"],
|
|
2439
|
+
attemptId: grant.metadata?.["attemptId"],
|
|
2440
|
+
executionGeneration: grant.metadata?.["executionGeneration"],
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function assertPlacementInstance(expected: string | null, actual: string): void {
|
|
2445
|
+
if (expected !== null && expected !== actual) {
|
|
2446
|
+
throw new BrowserSessionStateError("BrowserSession placement instance changed");
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function isTerminalOperation(state: string): boolean {
|
|
2451
|
+
return state === "completed" || state === "failed" || state === "outcome_unknown";
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function interactionFailure(error: unknown) {
|
|
2455
|
+
if (error instanceof BrowserControlRequestError) return error.error;
|
|
2456
|
+
if (
|
|
2457
|
+
error instanceof BrowserControlUnsupportedError ||
|
|
2458
|
+
error instanceof BrowserControlServerUnsupportedError
|
|
2459
|
+
) {
|
|
2460
|
+
return {
|
|
2461
|
+
code: "unsupported" as const,
|
|
2462
|
+
message: error.message,
|
|
2463
|
+
retryable: false,
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
if (error instanceof BrowserControlServerError) {
|
|
2467
|
+
return {
|
|
2468
|
+
code:
|
|
2469
|
+
error.stage === "engine_unavailable"
|
|
2470
|
+
? ("unsupported" as const)
|
|
2471
|
+
: ("driver_failed" as const),
|
|
2472
|
+
message: error.message,
|
|
2473
|
+
retryable: error.stage !== "engine_unavailable",
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
return {
|
|
2477
|
+
code: "driver_failed" as const,
|
|
2478
|
+
message: error instanceof Error ? error.message : "browser controller failed",
|
|
2479
|
+
retryable: false,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function browserRouteError(error: unknown): HTTPException {
|
|
2484
|
+
if (error instanceof HTTPException) return error;
|
|
2485
|
+
if (error instanceof BrowserSessionNotFoundError) {
|
|
2486
|
+
return new HTTPException(404, { message: error.message, cause: error });
|
|
2487
|
+
}
|
|
2488
|
+
if (error instanceof AttachedBrowserDeviceNotFoundError) {
|
|
2489
|
+
return new HTTPException(404, { message: error.message, cause: error });
|
|
2490
|
+
}
|
|
2491
|
+
if (error instanceof BrowserIdentityNotFoundError) {
|
|
2492
|
+
return new HTTPException(404, { message: error.message, cause: error });
|
|
2493
|
+
}
|
|
2494
|
+
if (
|
|
2495
|
+
error instanceof BrowserSessionOperationConflictError ||
|
|
2496
|
+
error instanceof BrowserSessionStateError ||
|
|
2497
|
+
error instanceof BrowserIdentityConflictError ||
|
|
2498
|
+
error instanceof BrowserIdentityStateError
|
|
2499
|
+
) {
|
|
2500
|
+
return new HTTPException(409, { message: error.message, cause: error });
|
|
2501
|
+
}
|
|
2502
|
+
if (error instanceof BrowserControlRequestError) {
|
|
2503
|
+
return new HTTPException(error.status as ContentfulStatusCode, {
|
|
2504
|
+
message: error.error.message,
|
|
2505
|
+
cause: error,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
if (error instanceof BrowserControlTransportError) {
|
|
2509
|
+
return new HTTPException(503, {
|
|
2510
|
+
message: "browser controller is unavailable",
|
|
2511
|
+
cause: error,
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
if (error instanceof BrowserControlProtocolError) {
|
|
2515
|
+
return new HTTPException(502, {
|
|
2516
|
+
message: "browser controller response is invalid",
|
|
2517
|
+
cause: error,
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
if (
|
|
2521
|
+
error instanceof BrowserControlUnsupportedError ||
|
|
2522
|
+
error instanceof BrowserControlServerUnsupportedError
|
|
2523
|
+
) {
|
|
2524
|
+
return new HTTPException(409, { message: error.message, cause: error });
|
|
2525
|
+
}
|
|
2526
|
+
if (error instanceof BrowserControlServerError) {
|
|
2527
|
+
return new HTTPException(error.stage === "engine_unavailable" ? 409 : 503, {
|
|
2528
|
+
message:
|
|
2529
|
+
error.stage === "engine_unavailable"
|
|
2530
|
+
? "browser engine is unavailable on this placement"
|
|
2531
|
+
: "browser controller could not start",
|
|
2532
|
+
cause: error,
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
return new HTTPException(500, {
|
|
2536
|
+
message: "BrowserSession request failed",
|
|
2537
|
+
cause: error,
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
function isAbort(error: unknown): boolean {
|
|
2542
|
+
return error instanceof Error && error.name === "AbortError";
|
|
2543
|
+
}
|