@opengeni/api-router 0.23.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -0,0 +1,4482 @@
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_PORT,
11
+ BROWSER_CONTROL_PROTOCOL_VERSION,
12
+ BROWSER_PROFILE_ARTIFACT_FORMAT,
13
+ BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
14
+ AttachedBrowserDevice,
15
+ AttachedBrowserDeviceListResponse,
16
+ BrowserActionCommand,
17
+ BrowserActionRequest,
18
+ BrowserDiagnosticKind,
19
+ BrowserDownload,
20
+ BrowserDownloadSaveRequest,
21
+ BrowserDownloadSaveResponse,
22
+ BrowserDownloadListResponse,
23
+ BrowserExternalAuthCommand,
24
+ BrowserOpenTargetRequest,
25
+ BrowserObservation,
26
+ BrowserProtectedAuthFillCommand,
27
+ BrowserSessionAttachment,
28
+ BrowserSessionAttachmentRequest,
29
+ BrowserSessionHeartbeatResponse,
30
+ BrowserSessionLifecycleRequest,
31
+ BrowserSessionListResponse,
32
+ BrowserSessionMutationResponse,
33
+ BrowserTargetListResponse,
34
+ CreateBrowserSessionRequest,
35
+ ExternalAuthInteractiveRequest,
36
+ ExternalAuthInteractiveResponse,
37
+ ExternalAuthRunRequest,
38
+ ExternalAuthRunResponse,
39
+ InteractionActor,
40
+ ProtectedAuthFillRequest,
41
+ ProtectedAuthFillResponse,
42
+ ReportAuthRunRequest,
43
+ StartAuthRunRequest,
44
+ VerifyAuthRunRequest,
45
+ PublishBrowserRevisionRequest,
46
+ PublishBrowserRevisionResponse,
47
+ type AccessGrant,
48
+ type BrowserProtectedAuthFillReceipt as BrowserProtectedAuthFillReceiptValue,
49
+ type BrowserActionRequest as BrowserActionRequestValue,
50
+ type BrowserDownload as BrowserDownloadValue,
51
+ type BrowserSession as BrowserSessionValue,
52
+ type FileAsset,
53
+ type CreateBrowserSessionRequest as CreateBrowserSessionRequestValue,
54
+ type InteractionPlacement,
55
+ type InteractionCredentialAuthorityRef,
56
+ type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
57
+ type Session,
58
+ type SessionAuthorizationOperation,
59
+ type SiteAuthAuthority,
60
+ } from "@opengeni/contracts";
61
+ import {
62
+ acquireLease,
63
+ activateBrowserSession,
64
+ ATTACHED_BROWSER_SESSION_CAPABILITIES,
65
+ EXTERNAL_BROWSER_SESSION_CAPABILITIES,
66
+ LIGHTPANDA_BROWSER_SESSION_CAPABILITIES,
67
+ bindBrowserSessionNetworkRouteAuthority,
68
+ AttachedBrowserDeviceNotFoundError,
69
+ BrowserIdentityConflictError,
70
+ BrowserIdentityNotFoundError,
71
+ BrowserIdentityStateError,
72
+ BrowserStateUploadStateError,
73
+ clearSuspendedBrowserSessionController,
74
+ BrowserSessionNotFoundError,
75
+ BrowserSessionOperationConflictError,
76
+ BrowserSessionStateError,
77
+ completeBrowserSessionEnd,
78
+ completeExternalAuth,
79
+ completeBrowserDownloadSave,
80
+ completeFileUpload,
81
+ completeProtectedAuthFill,
82
+ commitBrowserSessionSuspension,
83
+ commitBrowserRevisionPublication,
84
+ dispatchBrowserRevisionPublication,
85
+ dispatchBrowserDownloadSave,
86
+ dispatchBrowserSessionOperation,
87
+ dispatchExternalAuth,
88
+ dispatchProtectedAuthFill,
89
+ failBrowserSessionOperation,
90
+ failBrowserSessionResume,
91
+ failBrowserSessionResumePreparation,
92
+ failBrowserSessionSuspension,
93
+ failBrowserRevisionPublication,
94
+ findBrowserSessionControlRecordByOperation,
95
+ findBrowserDownloadSave,
96
+ getBrowserSessionControlRecord,
97
+ getComputerSessionControlRecord,
98
+ getBrowserPrivateCheckpointAuthority,
99
+ getAttachedBrowserDevice,
100
+ getBrowserRevisionArtifactAuthority,
101
+ getEnrollment,
102
+ getExternalAuthInteractiveContext,
103
+ getFiles,
104
+ getFileUpload,
105
+ getAuthRun,
106
+ getExternalAuthPreparation,
107
+ getProtectedAuthFillPreparation,
108
+ getSiteAuthConnection,
109
+ getSession,
110
+ InteractionResourceConflictError,
111
+ InteractionResourceNotFoundError,
112
+ InteractionResourceStateError,
113
+ listAuthRuns,
114
+ listBrowserSessions,
115
+ listAttachedBrowserDevices,
116
+ prepareBrowserSessionCreate,
117
+ prepareExternalAuth,
118
+ prepareBrowserDownloadSave,
119
+ prepareBrowserSessionEnd,
120
+ prepareBrowserSessionResume,
121
+ prepareBrowserSessionSuspend,
122
+ prepareBrowserRevisionPublication,
123
+ prepareProtectedAuthFill,
124
+ reportAuthRun,
125
+ releaseLeaseHolder,
126
+ readLease,
127
+ recordLeaseControllerDataPlaneUrl,
128
+ loadConnectionCredentialForBroker,
129
+ markProtectedAuthFillOutcomeUnknown,
130
+ startAuthRun,
131
+ touchBrowserSessionController,
132
+ verifyAuthRun,
133
+ settleBrowserDownloadSaveFailure,
134
+ type BrowserSessionControlRecord,
135
+ type BrowserPrivateCheckpointAuthority,
136
+ type BrowserRevisionArtifactAuthority,
137
+ type LeaseSnapshot,
138
+ } from "@opengeni/db";
139
+ import {
140
+ requireAccessGrant,
141
+ requireSessionAuthorization,
142
+ recordWorkspaceUsage,
143
+ requireLimit,
144
+ relayConfigFromSettings,
145
+ SessionAuthorizationDeniedError,
146
+ SessionAuthorizationUnavailableError,
147
+ type ApiRouteDeps,
148
+ } from "@opengeni/core";
149
+ import {
150
+ BrowserControlProtocolError,
151
+ BrowserControlClient,
152
+ BrowserControlRequestError,
153
+ BrowserControlServerError,
154
+ BrowserControlServerUnsupportedError,
155
+ BrowserControlTransportError,
156
+ BrowserControlUnsupportedError,
157
+ buildStreamUrl,
158
+ exposedPortEndpointFromUrl,
159
+ buildSelfhostedBackendSession,
160
+ mintStreamToken,
161
+ NatsControlRpc,
162
+ NatsOpStreamTransport,
163
+ provisionBrowserControlClient,
164
+ type BrowserControlPlacementSession,
165
+ type PlacementBrowserStateCaptureReceipt,
166
+ type PlacementBrowserNetworkRoute,
167
+ type PlacementBrowserTransport,
168
+ type RestorePlacementBrowserStateInput,
169
+ } from "@opengeni/runtime/sandbox";
170
+ import type { Context, Hono } from "hono";
171
+ import { HTTPException } from "hono/http-exception";
172
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
173
+ import {
174
+ deriveBrowserControllerAdminToken,
175
+ deriveBrowserNetworkRouteAuthorityDigest,
176
+ deriveBrowserSessionControllerTokens,
177
+ deriveBrowserViewGrantToken,
178
+ } from "../browser-controller-authority";
179
+ import { withCachedController } from "../controller-data-plane";
180
+ import { withInteractionHolderHeartbeat } from "../interaction-holder-heartbeat";
181
+ import {
182
+ browserStateArtifactAad,
183
+ browserStateManifestDigest,
184
+ browserStateObjectKey,
185
+ deriveBrowserStateDataKey,
186
+ unwrapBrowserStateDataKey,
187
+ wrapBrowserStateDataKey,
188
+ } from "../browser-state-authority";
189
+ import {
190
+ BrowserAuthCredentialError,
191
+ resolveProtectedAuthFieldValues,
192
+ } from "../browser-auth-broker";
193
+ import { managedNetworkRouteForPlacement } from "../browser-network-route";
194
+ import { allowedCorsOrigin } from "../http/cors";
195
+ import {
196
+ observeAuthMutation,
197
+ observeBrowserActionResult,
198
+ observeBrowserRevisionPublication,
199
+ observeLifecycleResult,
200
+ } from "../interaction-metrics";
201
+ import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
202
+ import { sanitizeFilename } from "./files";
203
+
204
+ const BROWSER_DRIVER_ID = "opengeni.cdp.v1";
205
+ const LIGHTPANDA_DRIVER_ID = "opengeni.lightpanda.cdp.v1";
206
+ const EXTERNAL_BROWSER_DRIVER_ID = "opengeni.external.cdp.v1";
207
+ const BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS = 20 * 60;
208
+ const BROWSER_STATE_UPLOAD_CLEANUP_GRACE_MS = 24 * 60 * 60 * 1_000;
209
+
210
+ type BrowserPlacement = {
211
+ placement: InteractionPlacement;
212
+ controllerHostSandboxGroupId: string | null;
213
+ placementInstanceId: string;
214
+ session: BrowserControlPlacementSession;
215
+ lease: LeaseSnapshot | null;
216
+ transport: PlacementBrowserTransport;
217
+ };
218
+
219
+ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
220
+ const channelServices = {
221
+ db: deps.db,
222
+ settings: deps.settings,
223
+ bus: deps.bus,
224
+ observability: deps.observability,
225
+ };
226
+
227
+ app.get("/v1/workspaces/:workspaceId/attached-browsers", async (context) => {
228
+ const workspaceId = context.req.param("workspaceId") ?? "";
229
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
230
+ const includeDisconnected = context.req.query("includeDisconnected") === "true";
231
+ return context.json(
232
+ AttachedBrowserDeviceListResponse.parse(
233
+ await listAttachedBrowserDevices(deps.db, {
234
+ accountId: grant.accountId,
235
+ workspaceId,
236
+ includeDisconnected,
237
+ }),
238
+ ),
239
+ );
240
+ });
241
+
242
+ app.get("/v1/workspaces/:workspaceId/attached-browsers/:deviceId", async (context) => {
243
+ const workspaceId = context.req.param("workspaceId") ?? "";
244
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
245
+ const deviceId = requireUuidParam(context, "deviceId");
246
+ try {
247
+ return context.json(
248
+ AttachedBrowserDevice.parse(
249
+ await getAttachedBrowserDevice(deps.db, {
250
+ accountId: grant.accountId,
251
+ workspaceId,
252
+ deviceId,
253
+ }),
254
+ ),
255
+ );
256
+ } catch (error) {
257
+ if (error instanceof AttachedBrowserDeviceNotFoundError) {
258
+ throw new HTTPException(404, { message: error.message });
259
+ }
260
+ throw error;
261
+ }
262
+ });
263
+
264
+ app.get("/v1/workspaces/:workspaceId/browser-sessions", async (context) => {
265
+ const workspaceId = context.req.param("workspaceId") ?? "";
266
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
267
+ return context.json(
268
+ BrowserSessionListResponse.parse(
269
+ await listBrowserSessions(deps.db, {
270
+ accountId: grant.accountId,
271
+ workspaceId,
272
+ }),
273
+ ),
274
+ );
275
+ });
276
+
277
+ app.get("/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId", async (context) => {
278
+ const workspaceId = context.req.param("workspaceId");
279
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
280
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
281
+ try {
282
+ const record = await getBrowserSessionControlRecord(deps.db, {
283
+ accountId: grant.accountId,
284
+ workspaceId,
285
+ browserSessionId,
286
+ });
287
+ await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.read");
288
+ return context.json(record.session);
289
+ } catch (error) {
290
+ throw browserRouteError(error);
291
+ }
292
+ });
293
+
294
+ app.post("/v1/workspaces/:workspaceId/browser-sessions", async (context) => {
295
+ const workspaceId = context.req.param("workspaceId");
296
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
297
+ const request = await parseJsonBody(context, CreateBrowserSessionRequest);
298
+ const startedAtMs = performance.now();
299
+ await authorizeSourceSession(deps, grant, request.sessionId, "session.control");
300
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
301
+ const authority = browserAuthorityRoot(deps);
302
+
303
+ try {
304
+ const existing = await findBrowserSessionControlRecordByOperation(deps.db, {
305
+ accountId: grant.accountId,
306
+ workspaceId,
307
+ operationId: request.operationId,
308
+ });
309
+ if (existing && existing.sourceSessionId !== request.sessionId) {
310
+ throw new BrowserSessionOperationConflictError(
311
+ "BrowserSession create operation belongs to another source session",
312
+ );
313
+ }
314
+ if (existing) assertCreateReplay(request, existing.session);
315
+ let prepared = existing
316
+ ? await prepareBrowserSessionCreate(
317
+ deps.db,
318
+ browserCreateInput(grant, workspaceId, request, existing.session.placement),
319
+ )
320
+ : null;
321
+ if (prepared && isTerminalOperation(prepared.operation.state)) {
322
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
323
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
324
+ return context.json(parsed, 200);
325
+ }
326
+
327
+ const sourceSession = await requireSourceSession(deps, workspaceId, request.sessionId);
328
+ const response = await withBrowserPlacement(
329
+ sourceSession,
330
+ grant,
331
+ existing?.session.placement ?? request.placement ?? null,
332
+ existing?.session.controller?.placementInstanceId ?? null,
333
+ "browser.create",
334
+ context.req.raw.signal,
335
+ async (placement) => {
336
+ let revisionAuthority: BrowserRevisionArtifactAuthority | null = null;
337
+ if (!prepared) {
338
+ prepared = await prepareBrowserSessionCreate(
339
+ deps.db,
340
+ browserCreateInput(grant, workspaceId, request, placement.placement),
341
+ );
342
+ }
343
+ if (isTerminalOperation(prepared.operation.state)) return prepared;
344
+
345
+ if (prepared.session.baseRevisionId && !revisionAuthority) {
346
+ if (!prepared.session.identityId) {
347
+ throw new BrowserIdentityStateError("BrowserSession base revision has no identity");
348
+ }
349
+ revisionAuthority = await getBrowserRevisionArtifactAuthority(deps.db, {
350
+ accountId: grant.accountId,
351
+ workspaceId,
352
+ identityId: prepared.session.identityId,
353
+ revisionId: prepared.session.baseRevisionId,
354
+ });
355
+ }
356
+ const restore = await prepareBrowserStateRestore(
357
+ deps,
358
+ grant,
359
+ prepared.session,
360
+ placement.placement,
361
+ revisionAuthority,
362
+ );
363
+
364
+ try {
365
+ const networkRoute = await resolveBrowserNetworkRouteLaunch({
366
+ deps,
367
+ grant,
368
+ workspaceId,
369
+ browserSessionId: prepared.session.id,
370
+ operationId: request.operationId,
371
+ rootSecret: authority,
372
+ placement: placement.placement,
373
+ });
374
+ const interactionHeld = await ensureInteractionHolder(
375
+ deps,
376
+ grant,
377
+ sourceSession,
378
+ prepared.session.id,
379
+ placement,
380
+ context.req.raw.signal,
381
+ );
382
+ const record = await ensureDispatchedGeneration(
383
+ deps,
384
+ grant,
385
+ workspaceId,
386
+ prepared.session.id,
387
+ request.operationId,
388
+ placement.placementInstanceId,
389
+ ).catch(async (error: unknown) => {
390
+ if (interactionHeld) {
391
+ await releaseInteractionHolder(
392
+ deps,
393
+ grant,
394
+ workspaceId,
395
+ prepared!.session.id,
396
+ placement.controllerHostSandboxGroupId,
397
+ ).catch(() => undefined);
398
+ }
399
+ throw error;
400
+ });
401
+ const preparedSession = prepared.session;
402
+ const controllerGeneration = requireOperationGeneration(record);
403
+ const linkedComputer = await requireLinkedComputerBinding(
404
+ deps,
405
+ grant,
406
+ prepared.session,
407
+ placement,
408
+ );
409
+ const adminToken = deriveBrowserControllerAdminToken({
410
+ rootSecret: authority,
411
+ accountId: grant.accountId,
412
+ workspaceId,
413
+ placement: placement.placement,
414
+ placementInstanceId: placement.placementInstanceId,
415
+ });
416
+ const tokens = deriveBrowserSessionControllerTokens({
417
+ rootSecret: authority,
418
+ accountId: grant.accountId,
419
+ workspaceId,
420
+ browserSessionId: prepared.session.id,
421
+ placement: placement.placement,
422
+ placementInstanceId: placement.placementInstanceId,
423
+ controllerGeneration,
424
+ tokenGeneration: record.tokenGeneration,
425
+ });
426
+ try {
427
+ await withBrowserCreationController(
428
+ deps,
429
+ grant,
430
+ workspaceId,
431
+ placement,
432
+ adminToken,
433
+ origin,
434
+ async (client) =>
435
+ await withInteractionHolderHeartbeat(
436
+ deps,
437
+ {
438
+ grant,
439
+ workspaceId,
440
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
441
+ holderId: interactionHolderId(preparedSession.id),
442
+ operationId: request.operationId,
443
+ resourceId: preparedSession.id,
444
+ controllerGeneration,
445
+ },
446
+ async () =>
447
+ await client.createSession({
448
+ browserSessionId: preparedSession.id,
449
+ controllerGeneration,
450
+ tokenGeneration: record.tokenGeneration,
451
+ ...tokens,
452
+ headed: !preparedSession.headless,
453
+ transport: browserRuntimeTransport(preparedSession, placement.transport),
454
+ ...(linkedComputer ? { linkedComputer } : {}),
455
+ ...(networkRoute ? { networkRoute } : {}),
456
+ ...(request.initialUrl ? { initialUrl: request.initialUrl } : {}),
457
+ ...(restore ? { restore } : {}),
458
+ }),
459
+ ),
460
+ );
461
+ await cacheBrowserControllerPlacement(grant, workspaceId, placement).catch(
462
+ () => placement,
463
+ );
464
+ } catch (error) {
465
+ if (
466
+ error instanceof BrowserControlTransportError ||
467
+ (error instanceof BrowserControlRequestError && error.retryable) ||
468
+ isAbort(error)
469
+ )
470
+ throw error;
471
+ const failure = interactionFailure(error);
472
+ const failed = await failBrowserSessionOperation(deps.db, {
473
+ accountId: grant.accountId,
474
+ workspaceId,
475
+ operationId: request.operationId,
476
+ browserSessionId: prepared.session.id,
477
+ ...(error instanceof BrowserControlProtocolError
478
+ ? { state: "outcome_unknown" as const }
479
+ : {}),
480
+ error: failure,
481
+ });
482
+ if (interactionHeld && !(error instanceof BrowserControlProtocolError)) {
483
+ await releaseInteractionHolder(
484
+ deps,
485
+ grant,
486
+ workspaceId,
487
+ prepared.session.id,
488
+ placement.controllerHostSandboxGroupId,
489
+ ).catch(() => undefined);
490
+ }
491
+ return failed;
492
+ }
493
+ // Once the controller has accepted the exact binding, a persistence
494
+ // failure is retried against that same idempotent physical session.
495
+ // Never terminalize or release its placement authority here.
496
+ return await activateBrowserSession(deps.db, {
497
+ accountId: grant.accountId,
498
+ workspaceId,
499
+ operationId: request.operationId,
500
+ browserSessionId: prepared.session.id,
501
+ controller: {
502
+ controllerId: "opengeni-browserd",
503
+ controllerGeneration,
504
+ placementInstanceId: placement.placementInstanceId,
505
+ },
506
+ engineVersion: null,
507
+ });
508
+ } finally {
509
+ restore?.dataKey.fill(0);
510
+ restore?.aad.fill(0);
511
+ }
512
+ },
513
+ );
514
+ const parsed = BrowserSessionMutationResponse.parse(response);
515
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
516
+ return context.json(
517
+ parsed,
518
+ parsed.operation.state === "completed" && !parsed.operation.replayed ? 201 : 200,
519
+ );
520
+ } catch (error) {
521
+ throw browserRouteError(error);
522
+ }
523
+ });
524
+
525
+ app.get(
526
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets",
527
+ async (context) => {
528
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
529
+ context,
530
+ "sessions:read",
531
+ );
532
+ const result = await withActiveBrowserController(
533
+ context,
534
+ grant,
535
+ workspaceId,
536
+ browserSessionId,
537
+ "session.read",
538
+ "browser.read",
539
+ async ({ sessionClient, binding }) =>
540
+ BrowserTargetListResponse.parse({
541
+ browserSessionId,
542
+ controllerGeneration: binding.controllerGeneration,
543
+ targets: await sessionClient.listTargets(),
544
+ }),
545
+ );
546
+ return context.json(result);
547
+ },
548
+ );
549
+
550
+ app.post(
551
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets",
552
+ async (context) => {
553
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
554
+ context,
555
+ "sessions:control",
556
+ );
557
+ const request = await parseJsonBody(context, BrowserOpenTargetRequest);
558
+ const result = await withActiveBrowserController(
559
+ context,
560
+ grant,
561
+ workspaceId,
562
+ browserSessionId,
563
+ "session.control",
564
+ "browser.control",
565
+ async ({ sessionClient }) =>
566
+ BrowserObservation.parse(await sessionClient.openTarget(request.url)),
567
+ );
568
+ return context.json(result, 201);
569
+ },
570
+ );
571
+
572
+ app.post(
573
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/select",
574
+ async (context) => {
575
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
576
+ context,
577
+ "sessions:control",
578
+ );
579
+ await parseEmptyJsonBody(context);
580
+ const targetId = requireOpaqueParam(context, "targetId");
581
+ const result = await withActiveBrowserController(
582
+ context,
583
+ grant,
584
+ workspaceId,
585
+ browserSessionId,
586
+ "session.control",
587
+ "browser.control",
588
+ async ({ sessionClient }) =>
589
+ BrowserObservation.parse(await sessionClient.selectTarget(targetId)),
590
+ );
591
+ return context.json(result);
592
+ },
593
+ );
594
+
595
+ app.delete(
596
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId",
597
+ async (context) => {
598
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
599
+ context,
600
+ "sessions:control",
601
+ );
602
+ const targetId = requireOpaqueParam(context, "targetId");
603
+ const result = await withActiveBrowserController(
604
+ context,
605
+ grant,
606
+ workspaceId,
607
+ browserSessionId,
608
+ "session.control",
609
+ "browser.control",
610
+ async ({ sessionClient, binding }) =>
611
+ BrowserTargetListResponse.parse({
612
+ browserSessionId,
613
+ controllerGeneration: binding.controllerGeneration,
614
+ targets: await sessionClient.closeTarget(targetId),
615
+ }),
616
+ );
617
+ return context.json(result);
618
+ },
619
+ );
620
+
621
+ app.get(
622
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/downloads",
623
+ async (context) => {
624
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
625
+ context,
626
+ "sessions:read",
627
+ );
628
+ const result = await withActiveBrowserController(
629
+ context,
630
+ grant,
631
+ workspaceId,
632
+ browserSessionId,
633
+ "session.read",
634
+ "browser.read",
635
+ async ({ sessionClient }) => await sessionClient.listDownloads(),
636
+ );
637
+ return context.json(BrowserDownloadListResponse.parse(result));
638
+ },
639
+ );
640
+
641
+ app.get(
642
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/downloads/:downloadId",
643
+ async (context) => {
644
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
645
+ context,
646
+ "sessions:read",
647
+ );
648
+ const downloadId = requireUuidParam(context, "downloadId");
649
+ const result = await withActiveBrowserController(
650
+ context,
651
+ grant,
652
+ workspaceId,
653
+ browserSessionId,
654
+ "session.read",
655
+ "browser.read",
656
+ async ({ sessionClient }) => await sessionClient.download(downloadId),
657
+ );
658
+ return context.json(BrowserDownload.parse(result));
659
+ },
660
+ );
661
+
662
+ app.post(
663
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/downloads/:downloadId/save",
664
+ async (context) => {
665
+ const workspaceId = context.req.param("workspaceId") ?? "";
666
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
667
+ await requireAccessGrant(context, deps, workspaceId, "files:upload");
668
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
669
+ const downloadId = requireUuidParam(context, "downloadId");
670
+ const request = await parseJsonBody(context, BrowserDownloadSaveRequest);
671
+ const objectStorage = deps.objectStorage;
672
+ const identity = {
673
+ accountId: grant.accountId,
674
+ workspaceId,
675
+ actorSubjectId: grant.subjectId,
676
+ browserSessionId,
677
+ downloadId,
678
+ request,
679
+ };
680
+
681
+ try {
682
+ let save = await findBrowserDownloadSave(deps.db, identity);
683
+ if (save?.response) {
684
+ return context.json(BrowserDownloadSaveResponse.parse(save.response), 200);
685
+ }
686
+ if (!objectStorage) {
687
+ throw new HTTPException(503, { message: "object storage is not configured" });
688
+ }
689
+ if (save && isTerminalOperation(save.state)) {
690
+ throw new InteractionResourceStateError(
691
+ `Browser download save is ${save.state.replace("_", " ")}`,
692
+ );
693
+ }
694
+
695
+ if (!save) {
696
+ const source = await withActiveBrowserController(
697
+ context,
698
+ grant,
699
+ workspaceId,
700
+ browserSessionId,
701
+ "session.control",
702
+ "browser.read",
703
+ async ({ sessionClient, record, binding }) => {
704
+ const download = await sessionClient.download(downloadId);
705
+ if (
706
+ download.status !== "completed" ||
707
+ !download.sha256 ||
708
+ download.controllerGeneration !== binding.controllerGeneration
709
+ ) {
710
+ throw new InteractionResourceStateError("Browser download is not ready to save");
711
+ }
712
+ return { download, sourceSessionId: record.sourceSessionId };
713
+ },
714
+ );
715
+ if (source.download.receivedBytes > objectStorage.maxSinglePutSizeBytes) {
716
+ throw new HTTPException(413, {
717
+ message: `browser download exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`,
718
+ });
719
+ }
720
+ const fileId = randomUUID();
721
+ const safeFilename = sanitizeFilename(source.download.filename);
722
+ save = await prepareBrowserDownloadSave(deps.db, {
723
+ ...identity,
724
+ sourceSessionId: source.sourceSessionId,
725
+ download: source.download,
726
+ fileId,
727
+ safeFilename,
728
+ contentType: "application/octet-stream",
729
+ bucket: objectStorage.bucket,
730
+ objectKey: `workspaces/${workspaceId}/files/${fileId}/original/${safeFilename}`,
731
+ uploadExpiresAt: new Date(
732
+ Date.now() + BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS * 1_000,
733
+ ),
734
+ });
735
+ }
736
+
737
+ if (save.download.receivedBytes > objectStorage.maxSinglePutSizeBytes) {
738
+ throw new HTTPException(413, {
739
+ message: `browser download exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`,
740
+ });
741
+ }
742
+ await requireLimit(deps, {
743
+ accountId: grant.accountId,
744
+ workspaceId,
745
+ action: "file:upload",
746
+ quantity: save.download.receivedBytes,
747
+ });
748
+
749
+ let upload = await getFileUpload(deps.db, workspaceId, save.uploadId);
750
+ if (!upload || upload.file.id !== save.fileId || upload.file.objectKey !== save.objectKey) {
751
+ throw new InteractionResourceStateError("Browser download file authority is unavailable");
752
+ }
753
+ const controllerRecord = await getBrowserSessionControlRecord(deps.db, {
754
+ accountId: grant.accountId,
755
+ workspaceId,
756
+ browserSessionId,
757
+ });
758
+ if (upload.status === "pending") {
759
+ if (save.state !== "prepared") {
760
+ throw new InteractionResourceStateError(
761
+ "Browser download save was dispatched before its bytes were published",
762
+ );
763
+ }
764
+ const put = await objectStorage.createPutUrl({
765
+ key: save.objectKey,
766
+ contentType: save.contentType,
767
+ sha256: save.download.sha256,
768
+ expiresInSeconds: BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS,
769
+ audience: signedUrlAudienceForPlacement(controllerRecord.session.placement),
770
+ });
771
+ save = await prepareBrowserDownloadSave(deps.db, {
772
+ ...identity,
773
+ sourceSessionId: save.sourceSessionId,
774
+ download: save.download,
775
+ fileId: save.fileId,
776
+ safeFilename: save.safeFilename,
777
+ contentType: save.contentType,
778
+ bucket: upload.file.bucket,
779
+ objectKey: save.objectKey,
780
+ uploadExpiresAt: put.expiresAt,
781
+ });
782
+ const preparedSave = save;
783
+ const receipt = await withActiveBrowserController(
784
+ context,
785
+ grant,
786
+ workspaceId,
787
+ browserSessionId,
788
+ "session.control",
789
+ "browser.control",
790
+ async ({ sessionClient, record, binding }) => {
791
+ if (
792
+ record.sourceSessionId !== preparedSave.sourceSessionId ||
793
+ binding.controllerGeneration !== preparedSave.controllerGeneration
794
+ ) {
795
+ throw new InteractionResourceStateError(
796
+ "Browser download belongs to a stale controller",
797
+ );
798
+ }
799
+ const current = await sessionClient.download(downloadId);
800
+ assertBrowserDownloadSaveSource(current, preparedSave.download);
801
+ return await sessionClient.exportDownload(downloadId, {
802
+ operationId: request.operationId,
803
+ downloadId,
804
+ upload: {
805
+ url: put.url,
806
+ requiredHeaders: put.requiredHeaders,
807
+ expiresAt: put.expiresAt.toISOString(),
808
+ },
809
+ });
810
+ },
811
+ );
812
+ if (
813
+ receipt.sizeBytes !== save.download.receivedBytes ||
814
+ receipt.sha256 !== save.download.sha256
815
+ ) {
816
+ throw new BrowserControlProtocolError(
817
+ "browser controller returned another download export",
818
+ );
819
+ }
820
+ upload = await finalizeBrowserDownloadFile(deps, grant, workspaceId, save.uploadId);
821
+ } else if (upload.status === "completed" && upload.file.status === "ready") {
822
+ await recordBrowserDownloadFileUsage(deps, grant, workspaceId, upload.file);
823
+ } else {
824
+ throw new InteractionResourceStateError(
825
+ `Browser download file upload is ${upload.status.replace("_", " ")}`,
826
+ );
827
+ }
828
+
829
+ if (upload.file.status !== "ready") {
830
+ throw new InteractionResourceStateError("Browser download file is not ready");
831
+ }
832
+ const get = await objectStorage.createGetUrl({
833
+ key: upload.file.objectKey,
834
+ expiresInSeconds: BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS,
835
+ audience: signedUrlAudienceForPlacement(controllerRecord.session.placement),
836
+ });
837
+ const sourceSession = await requireSourceSession(deps, workspaceId, save.sourceSessionId);
838
+ await authorizeSourceSession(deps, grant, save.sourceSessionId, "session.control");
839
+ const dispatched = await dispatchBrowserDownloadSave(deps.db, identity);
840
+ const imported = await withChannelA(
841
+ channelServices,
842
+ {
843
+ accountId: grant.accountId,
844
+ workspaceId,
845
+ session: sourceSession,
846
+ subjectId: grant.subjectId,
847
+ waitSignal: context.req.raw.signal,
848
+ operation: "browser.download.save",
849
+ },
850
+ async ({ service }) =>
851
+ await service.importWorkspaceFile({
852
+ operationId: request.operationId,
853
+ destinationPath: save.destinationPath,
854
+ overwrite: save.overwrite,
855
+ mayReplaceExisting: save.overwrite && dispatched.dispatchedNow,
856
+ sizeBytes: save.download.receivedBytes,
857
+ sha256: save.download.sha256!,
858
+ source: {
859
+ url: get.url,
860
+ expiresAt: get.expiresAt.toISOString(),
861
+ },
862
+ }),
863
+ );
864
+ if (
865
+ imported.destinationPath !== save.destinationPath ||
866
+ imported.sizeBytes !== save.download.receivedBytes ||
867
+ imported.sha256 !== save.download.sha256
868
+ ) {
869
+ await settleBrowserDownloadSaveFailure(deps.db, {
870
+ ...identity,
871
+ state: "outcome_unknown",
872
+ errorCode: "workspace_import_receipt_mismatch",
873
+ });
874
+ throw new InteractionResourceStateError(
875
+ "Browser download workspace import outcome is unknown",
876
+ );
877
+ }
878
+ const response = await completeBrowserDownloadSave(deps.db, identity);
879
+ return context.json(
880
+ BrowserDownloadSaveResponse.parse(response),
881
+ response.replayed ? 200 : 201,
882
+ );
883
+ } catch (error) {
884
+ throw browserRouteError(error);
885
+ }
886
+ },
887
+ );
888
+
889
+ app.get(
890
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/observation",
891
+ async (context) => {
892
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
893
+ context,
894
+ "sessions:read",
895
+ );
896
+ const targetId = requireOpaqueParam(context, "targetId");
897
+ const result = await withActiveBrowserController(
898
+ context,
899
+ grant,
900
+ workspaceId,
901
+ browserSessionId,
902
+ "session.read",
903
+ "browser.read",
904
+ async ({ sessionClient }) => await sessionClient.observe(targetId),
905
+ );
906
+ return context.json(result);
907
+ },
908
+ );
909
+
910
+ app.post(
911
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/actions",
912
+ async (context) => {
913
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
914
+ context,
915
+ "sessions:control",
916
+ );
917
+ const request = await parseJsonBody(context, BrowserActionRequest);
918
+ const workspaceFileIds = browserUploadFileIds(request.action);
919
+ if (workspaceFileIds.length > 0) {
920
+ await requireAccessGrant(context, deps, workspaceId, "files:read");
921
+ }
922
+ const startedAtMs = performance.now();
923
+ const result = await withActiveBrowserController(
924
+ context,
925
+ grant,
926
+ workspaceId,
927
+ browserSessionId,
928
+ "session.control",
929
+ "browser.action",
930
+ async ({ sessionClient, binding, record }) => {
931
+ const command = BrowserActionCommand.parse({
932
+ protocolVersion: BROWSER_CONTROL_PROTOCOL_VERSION,
933
+ operationId: request.operationId,
934
+ browserSessionId,
935
+ controllerGeneration: binding.controllerGeneration,
936
+ targetId: request.targetId,
937
+ expectedTargetGeneration: request.expectedTargetGeneration,
938
+ expectedDocumentGeneration: request.expectedDocumentGeneration,
939
+ expectedFrameId: request.expectedFrameId,
940
+ actor: interactionActorForGrant(grant),
941
+ observationMode: request.observationMode,
942
+ action: request.action,
943
+ });
944
+ if (workspaceFileIds.length > 0) {
945
+ let alreadyAdmitted = false;
946
+ try {
947
+ await sessionClient.receipt(request.operationId);
948
+ alreadyAdmitted = true;
949
+ } catch (error) {
950
+ if (!(error instanceof BrowserControlRequestError && error.status === 404)) {
951
+ throw error;
952
+ }
953
+ }
954
+ if (!alreadyAdmitted) {
955
+ if (!deps.objectStorage) {
956
+ throw new HTTPException(503, {
957
+ message: "object storage is not configured",
958
+ });
959
+ }
960
+ const files = await getFiles(deps.db, workspaceId, workspaceFileIds);
961
+ const byId = new Map(files.map((file) => [file.id, file]));
962
+ const ordered = workspaceFileIds.map((fileId) => byId.get(fileId));
963
+ if (ordered.some((file) => !file)) {
964
+ throw new HTTPException(404, { message: "workspace file not found" });
965
+ }
966
+ if (ordered.some((file) => file!.status !== "ready")) {
967
+ throw new HTTPException(409, { message: "workspace file is not ready" });
968
+ }
969
+ const authorities = await Promise.all(
970
+ ordered.map(async (file) => {
971
+ const signed = await deps.objectStorage!.createGetUrl({
972
+ key: file!.objectKey,
973
+ expiresInSeconds: BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS,
974
+ audience: signedUrlAudienceForPlacement(record.session.placement),
975
+ });
976
+ return {
977
+ fileId: file!.id,
978
+ safeFilename: browserUploadFilename(file!.safeFilename),
979
+ sizeBytes: file!.sizeBytes,
980
+ sha256: browserUploadSha256(file!.sha256),
981
+ download: {
982
+ url: signed.url,
983
+ expiresAt: signed.expiresAt.toISOString(),
984
+ },
985
+ };
986
+ }),
987
+ );
988
+ await sessionClient.stageWorkspaceFiles({
989
+ operationId: request.operationId,
990
+ files: authorities,
991
+ });
992
+ }
993
+ }
994
+ return await sessionClient.action(command);
995
+ },
996
+ );
997
+ observeBrowserActionResult(deps.observability, startedAtMs, request, result);
998
+ return context.json(result);
999
+ },
1000
+ );
1001
+
1002
+ app.get(
1003
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/clipboard",
1004
+ async (context) => {
1005
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1006
+ context,
1007
+ "sessions:read",
1008
+ );
1009
+ const result = await withActiveBrowserController(
1010
+ context,
1011
+ grant,
1012
+ workspaceId,
1013
+ browserSessionId,
1014
+ "session.read",
1015
+ "browser.read",
1016
+ async ({ sessionClient }) => await sessionClient.readClipboard(),
1017
+ );
1018
+ return context.json(result);
1019
+ },
1020
+ );
1021
+
1022
+ app.get(
1023
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs",
1024
+ async (context) => {
1025
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1026
+ context,
1027
+ "sessions:read",
1028
+ );
1029
+ const result = await withActiveBrowserController(
1030
+ context,
1031
+ grant,
1032
+ workspaceId,
1033
+ browserSessionId,
1034
+ "session.read",
1035
+ "browser.read",
1036
+ async () =>
1037
+ await listAuthRuns(deps.db, {
1038
+ accountId: grant.accountId,
1039
+ workspaceId,
1040
+ browserSessionId,
1041
+ includeSettled: context.req.query("includeSettled") === "true",
1042
+ }),
1043
+ );
1044
+ return context.json(result);
1045
+ },
1046
+ );
1047
+
1048
+ app.post(
1049
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs",
1050
+ async (context) => {
1051
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1052
+ context,
1053
+ "sessions:control",
1054
+ );
1055
+ const request = await parseJsonBody(context, StartAuthRunRequest);
1056
+ const actor = interactionActorForGrant(grant);
1057
+ const startedAtMs = performance.now();
1058
+ const result = await withActiveBrowserController(
1059
+ context,
1060
+ grant,
1061
+ workspaceId,
1062
+ browserSessionId,
1063
+ "session.control",
1064
+ "browser.control",
1065
+ async ({ sessionClient, binding }) => {
1066
+ const observation = await sessionClient.observe(request.targetId);
1067
+ if (
1068
+ observation.target.targetGeneration !== request.expectedTargetGeneration ||
1069
+ observation.target.documentGeneration !== request.expectedDocumentGeneration
1070
+ ) {
1071
+ throw new InteractionResourceConflictError(
1072
+ "Auth run does not match the currently observed browser document",
1073
+ );
1074
+ }
1075
+ return await startAuthRun(deps.db, {
1076
+ accountId: grant.accountId,
1077
+ workspaceId,
1078
+ actorSubjectId: grant.subjectId,
1079
+ browserSessionId,
1080
+ controllerGeneration: binding.controllerGeneration,
1081
+ originatingSessionId:
1082
+ actor.kind === "agent" && actor.sessionId ? actor.sessionId : null,
1083
+ ...request,
1084
+ });
1085
+ },
1086
+ );
1087
+ observeAuthMutation(deps.observability, startedAtMs, result);
1088
+ return context.json(result, result.replayed ? 200 : 201);
1089
+ },
1090
+ );
1091
+
1092
+ app.get(
1093
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId",
1094
+ async (context) => {
1095
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1096
+ context,
1097
+ "sessions:read",
1098
+ );
1099
+ const authRunId = requireUuidParam(context, "authRunId");
1100
+ const result = await withActiveBrowserController(
1101
+ context,
1102
+ grant,
1103
+ workspaceId,
1104
+ browserSessionId,
1105
+ "session.read",
1106
+ "browser.read",
1107
+ async () => {
1108
+ const run = await getAuthRun(deps.db, {
1109
+ accountId: grant.accountId,
1110
+ workspaceId,
1111
+ authRunId,
1112
+ });
1113
+ assertAuthRunBrowser(run.browserSessionId, browserSessionId);
1114
+ return run;
1115
+ },
1116
+ );
1117
+ return context.json(result);
1118
+ },
1119
+ );
1120
+
1121
+ app.post(
1122
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/report",
1123
+ async (context) => {
1124
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1125
+ context,
1126
+ "sessions:control",
1127
+ );
1128
+ const authRunId = requireUuidParam(context, "authRunId");
1129
+ const request = await parseJsonBody(context, ReportAuthRunRequest);
1130
+ const startedAtMs = performance.now();
1131
+ const result = await withActiveBrowserController(
1132
+ context,
1133
+ grant,
1134
+ workspaceId,
1135
+ browserSessionId,
1136
+ "session.control",
1137
+ "browser.control",
1138
+ async ({ binding }) => {
1139
+ const current = await getAuthRun(deps.db, {
1140
+ accountId: grant.accountId,
1141
+ workspaceId,
1142
+ authRunId,
1143
+ });
1144
+ assertAuthRunBrowser(current.browserSessionId, browserSessionId);
1145
+ return await reportAuthRun(deps.db, {
1146
+ accountId: grant.accountId,
1147
+ workspaceId,
1148
+ actorSubjectId: grant.subjectId,
1149
+ authRunId,
1150
+ controllerGeneration: binding.controllerGeneration,
1151
+ ...request,
1152
+ });
1153
+ },
1154
+ );
1155
+ observeAuthMutation(deps.observability, startedAtMs, result);
1156
+ return context.json(result);
1157
+ },
1158
+ );
1159
+
1160
+ app.post(
1161
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/protected-fill",
1162
+ async (context) => {
1163
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1164
+ context,
1165
+ "sessions:control",
1166
+ );
1167
+ const authRunId = requireUuidParam(context, "authRunId");
1168
+ const request = await parseJsonBody(context, ProtectedAuthFillRequest);
1169
+ const startedAtMs = performance.now();
1170
+ try {
1171
+ const replay = await getProtectedAuthFillPreparation(deps.db, {
1172
+ accountId: grant.accountId,
1173
+ workspaceId,
1174
+ actorSubjectId: grant.subjectId,
1175
+ authRunId,
1176
+ ...request,
1177
+ });
1178
+ if (replay?.response) {
1179
+ const record = await getBrowserSessionControlRecord(deps.db, {
1180
+ accountId: grant.accountId,
1181
+ workspaceId,
1182
+ browserSessionId,
1183
+ });
1184
+ await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.control");
1185
+ assertAuthRunBrowser(replay.run.browserSessionId, browserSessionId);
1186
+ const response = ProtectedAuthFillResponse.parse(replay.response);
1187
+ observeAuthMutation(deps.observability, startedAtMs, response);
1188
+ return context.json(response);
1189
+ }
1190
+ } catch (error) {
1191
+ throw browserRouteError(error);
1192
+ }
1193
+ const result = await withActiveBrowserController(
1194
+ context,
1195
+ grant,
1196
+ workspaceId,
1197
+ browserSessionId,
1198
+ "session.control",
1199
+ "browser.control",
1200
+ async ({ sessionClient, binding, record }) => {
1201
+ const actor = interactionActorForGrant(grant);
1202
+ const scope = {
1203
+ accountId: grant.accountId,
1204
+ workspaceId,
1205
+ actorSubjectId: grant.subjectId,
1206
+ authRunId,
1207
+ };
1208
+ let preparation = await getProtectedAuthFillPreparation(deps.db, {
1209
+ ...scope,
1210
+ ...request,
1211
+ });
1212
+ let credential: Awaited<ReturnType<typeof loadConnectionCredentialForBroker>> = null;
1213
+ if (!preparation) {
1214
+ const run = await getAuthRun(deps.db, {
1215
+ accountId: grant.accountId,
1216
+ workspaceId,
1217
+ authRunId,
1218
+ });
1219
+ assertAuthRunBrowser(run.browserSessionId, browserSessionId);
1220
+ const connection = await getSiteAuthConnection(deps.db, {
1221
+ accountId: grant.accountId,
1222
+ workspaceId,
1223
+ siteAuthConnectionId: run.siteAuthConnectionId,
1224
+ });
1225
+ const authority = connection.authorities.find(
1226
+ (candidate) => candidate.id === request.authorityId,
1227
+ );
1228
+ if (!authority) {
1229
+ throw new InteractionResourceStateError("Auth authority is not configured");
1230
+ }
1231
+ if (authority.kind === "external_provider") {
1232
+ throw new InteractionResourceStateError(
1233
+ "External auth providers cannot use protected field fill",
1234
+ );
1235
+ }
1236
+ if (authority.kind === "connection_fields") {
1237
+ credential = await loadBoundBrowserCredential(deps, grant, workspaceId, authority);
1238
+ }
1239
+ preparation = await prepareProtectedAuthFill(deps.db, {
1240
+ ...scope,
1241
+ ...request,
1242
+ credentialVersion: credential?.version ?? null,
1243
+ });
1244
+ }
1245
+ assertAuthRunBrowser(preparation.run.browserSessionId, browserSessionId);
1246
+ if (preparation.run.controllerGeneration !== binding.controllerGeneration) {
1247
+ throw new InteractionResourceConflictError(
1248
+ "Auth run belongs to a stale browser controller",
1249
+ );
1250
+ }
1251
+ if (preparation.response) return preparation.response;
1252
+ if (
1253
+ preparation.operationState === "failed" ||
1254
+ preparation.operationState === "outcome_unknown"
1255
+ ) {
1256
+ throw new InteractionResourceStateError(
1257
+ `Protected-fill operation is ${preparation.operationState.replace("_", " ")}`,
1258
+ );
1259
+ }
1260
+ if (preparation.authority.kind === "human") {
1261
+ if (preparation.operationState !== "prepared") {
1262
+ throw new InteractionResourceStateError(
1263
+ "Human protected-fill operation entered an invalid dispatch state",
1264
+ );
1265
+ }
1266
+ return await completeProtectedAuthFill(deps.db, {
1267
+ ...scope,
1268
+ operationId: request.operationId,
1269
+ status: "needs_human",
1270
+ intervention: {
1271
+ originatingSessionId: record.sourceSessionId,
1272
+ originatingTurnId: actor.kind === "agent" ? (actor.turnId ?? null) : null,
1273
+ originatingAttemptId: actor.kind === "agent" ? (actor.attemptId ?? null) : null,
1274
+ originatingToolOperationId:
1275
+ actor.kind === "agent" && actor.attemptId ? request.operationId : null,
1276
+ kind: preparation.authority.fields.some((field) => field.purpose === "totp")
1277
+ ? "mfa"
1278
+ : "manual_login",
1279
+ reason: "Sign in or complete authentication in this browser tab.",
1280
+ expiresInSeconds: 900,
1281
+ },
1282
+ });
1283
+ }
1284
+ if (preparation.authority.kind === "external_provider") {
1285
+ throw new InteractionResourceStateError(
1286
+ "External auth providers cannot use protected field fill",
1287
+ );
1288
+ }
1289
+
1290
+ if (preparation.operationState === "dispatched") {
1291
+ try {
1292
+ const receipt = await sessionClient.protectedAuthReceipt(request.operationId);
1293
+ return await settleProtectedAuthReceipt({
1294
+ deps,
1295
+ scope,
1296
+ receipt,
1297
+ });
1298
+ } catch (error) {
1299
+ if (
1300
+ error instanceof BrowserControlRequestError &&
1301
+ error.error.code === "resource_not_found"
1302
+ ) {
1303
+ await markProtectedAuthFillOutcomeUnknown(deps.db, {
1304
+ ...scope,
1305
+ operationId: request.operationId,
1306
+ errorCode: "controller_receipt_missing",
1307
+ });
1308
+ throw new InteractionResourceStateError(
1309
+ "Protected-fill outcome is unknown after controller recovery",
1310
+ );
1311
+ }
1312
+ throw error;
1313
+ }
1314
+ }
1315
+
1316
+ credential ??= await loadBoundBrowserCredential(
1317
+ deps,
1318
+ grant,
1319
+ workspaceId,
1320
+ preparation.authority,
1321
+ );
1322
+ if (credential.version !== preparation.credentialVersion) {
1323
+ throw new InteractionResourceConflictError(
1324
+ "Credential changed after protected fill was prepared; use a new operation id",
1325
+ );
1326
+ }
1327
+ const fields = resolveProtectedAuthFieldValues({
1328
+ authority: preparation.authority,
1329
+ requestedFields: request.fields,
1330
+ credential: credential.credential,
1331
+ });
1332
+ await dispatchProtectedAuthFill(deps.db, {
1333
+ ...scope,
1334
+ operationId: request.operationId,
1335
+ });
1336
+ const receipt = await sessionClient.protectedAuthFill(
1337
+ BrowserProtectedAuthFillCommand.parse({
1338
+ protocolVersion: BROWSER_CONTROL_PROTOCOL_VERSION,
1339
+ operationId: request.operationId,
1340
+ browserSessionId,
1341
+ controllerGeneration: binding.controllerGeneration,
1342
+ targetId: preparation.run.targetId,
1343
+ expectedTargetGeneration: request.expectedTargetGeneration,
1344
+ expectedDocumentGeneration: request.expectedDocumentGeneration,
1345
+ expectedFrameId: request.expectedFrameId,
1346
+ actor,
1347
+ authorityId: preparation.authority.id,
1348
+ credentialVersion: preparation.credentialVersion,
1349
+ allowedOrigins: preparation.origins,
1350
+ fields,
1351
+ submit: request.submit,
1352
+ }),
1353
+ );
1354
+ return await settleProtectedAuthReceipt({ deps, scope, receipt });
1355
+ },
1356
+ );
1357
+ const response = ProtectedAuthFillResponse.parse(result);
1358
+ observeAuthMutation(deps.observability, startedAtMs, response);
1359
+ return context.json(response);
1360
+ },
1361
+ );
1362
+
1363
+ app.post(
1364
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/external-auth",
1365
+ async (context) => {
1366
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1367
+ context,
1368
+ "sessions:control",
1369
+ );
1370
+ const authRunId = requireUuidParam(context, "authRunId");
1371
+ const request = await parseJsonBody(context, ExternalAuthRunRequest);
1372
+ const startedAtMs = performance.now();
1373
+ try {
1374
+ const replay = await getExternalAuthPreparation(deps.db, {
1375
+ accountId: grant.accountId,
1376
+ workspaceId,
1377
+ actorSubjectId: grant.subjectId,
1378
+ authRunId,
1379
+ ...request,
1380
+ });
1381
+ if (replay?.response) {
1382
+ const record = await getBrowserSessionControlRecord(deps.db, {
1383
+ accountId: grant.accountId,
1384
+ workspaceId,
1385
+ browserSessionId,
1386
+ });
1387
+ await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.control");
1388
+ assertAuthRunBrowser(replay.run.browserSessionId, browserSessionId);
1389
+ const response = ExternalAuthRunResponse.parse(replay.response);
1390
+ observeAuthMutation(deps.observability, startedAtMs, response);
1391
+ return context.json(response);
1392
+ }
1393
+ } catch (error) {
1394
+ throw browserRouteError(error);
1395
+ }
1396
+ const result = await withActiveBrowserController(
1397
+ context,
1398
+ grant,
1399
+ workspaceId,
1400
+ browserSessionId,
1401
+ "session.control",
1402
+ "browser.control",
1403
+ async ({ sessionClient, binding, record }) => {
1404
+ const actor = interactionActorForGrant(grant);
1405
+ const scope = {
1406
+ accountId: grant.accountId,
1407
+ workspaceId,
1408
+ actorSubjectId: grant.subjectId,
1409
+ authRunId,
1410
+ };
1411
+ let preparation = await getExternalAuthPreparation(deps.db, {
1412
+ ...scope,
1413
+ ...request,
1414
+ });
1415
+ preparation ??= await prepareExternalAuth(deps.db, {
1416
+ ...scope,
1417
+ ...request,
1418
+ });
1419
+ assertAuthRunBrowser(preparation.run.browserSessionId, browserSessionId);
1420
+ if (preparation.run.controllerGeneration !== binding.controllerGeneration) {
1421
+ throw new InteractionResourceConflictError(
1422
+ "Auth run belongs to a stale browser controller",
1423
+ );
1424
+ }
1425
+ if (preparation.response) return preparation.response;
1426
+ if (
1427
+ preparation.operationState === "failed" ||
1428
+ preparation.operationState === "outcome_unknown"
1429
+ ) {
1430
+ throw new InteractionResourceStateError(
1431
+ `External-auth operation is ${preparation.operationState.replace("_", " ")}`,
1432
+ );
1433
+ }
1434
+ if (preparation.operationState === "prepared") {
1435
+ await dispatchExternalAuth(deps.db, {
1436
+ ...scope,
1437
+ operationId: request.operationId,
1438
+ });
1439
+ }
1440
+ const providerResult = await sessionClient.externalAuth(
1441
+ BrowserExternalAuthCommand.parse({
1442
+ browserSessionId,
1443
+ controllerGeneration: binding.controllerGeneration,
1444
+ operationId: request.operationId,
1445
+ authRunId,
1446
+ adapterId: preparation.authority.adapterId,
1447
+ connectionId: preparation.authority.connectionId,
1448
+ action: request.action,
1449
+ }),
1450
+ );
1451
+ if (providerResult.interactiveUrl !== null) {
1452
+ throw new BrowserControlProtocolError(
1453
+ "provider exposed a hosted login URL outside the human-only endpoint",
1454
+ );
1455
+ }
1456
+ let target:
1457
+ | { id: string; targetGeneration: string; documentGeneration: string | null }
1458
+ | undefined;
1459
+ if (providerResult.state === "authenticated") {
1460
+ const targets = await sessionClient.listTargets();
1461
+ const selected = targets.find((candidate) => candidate.selected) ?? targets[0];
1462
+ if (!selected) {
1463
+ throw new BrowserControlProtocolError(
1464
+ "authenticated provider browser returned no current target",
1465
+ );
1466
+ }
1467
+ const observation = await sessionClient.observe(selected.id);
1468
+ target = {
1469
+ id: observation.target.id,
1470
+ targetGeneration: observation.target.targetGeneration,
1471
+ documentGeneration: observation.target.documentGeneration,
1472
+ };
1473
+ }
1474
+ return await completeExternalAuth(deps.db, {
1475
+ ...scope,
1476
+ operationId: request.operationId,
1477
+ result: providerResult,
1478
+ ...(target ? { target } : {}),
1479
+ ...(providerResult.state === "needs_human"
1480
+ ? {
1481
+ intervention: {
1482
+ originatingSessionId: record.sourceSessionId,
1483
+ originatingTurnId: actor.kind === "agent" ? (actor.turnId ?? null) : null,
1484
+ originatingAttemptId: actor.kind === "agent" ? (actor.attemptId ?? null) : null,
1485
+ originatingToolOperationId:
1486
+ actor.kind === "agent" && actor.attemptId ? request.operationId : null,
1487
+ expiresInSeconds: 1_200,
1488
+ },
1489
+ }
1490
+ : {}),
1491
+ });
1492
+ },
1493
+ );
1494
+ const response = ExternalAuthRunResponse.parse(result);
1495
+ observeAuthMutation(deps.observability, startedAtMs, response);
1496
+ return context.json(response);
1497
+ },
1498
+ );
1499
+
1500
+ app.post(
1501
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/external-auth/interactive",
1502
+ async (context) => {
1503
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1504
+ context,
1505
+ "sessions:control",
1506
+ );
1507
+ if (grant.principalKind !== "human_session") {
1508
+ throw new HTTPException(403, {
1509
+ message: "hosted login flows can only be opened by an interactive user",
1510
+ });
1511
+ }
1512
+ const authRunId = requireUuidParam(context, "authRunId");
1513
+ const request = await parseJsonBody(context, ExternalAuthInteractiveRequest);
1514
+ const result = await withActiveBrowserController(
1515
+ context,
1516
+ grant,
1517
+ workspaceId,
1518
+ browserSessionId,
1519
+ "session.control",
1520
+ "browser.control",
1521
+ async ({ sessionClient, binding }) => {
1522
+ const resolved = await getExternalAuthInteractiveContext(deps.db, {
1523
+ accountId: grant.accountId,
1524
+ workspaceId,
1525
+ actorSubjectId: grant.subjectId,
1526
+ authRunId,
1527
+ ...request,
1528
+ });
1529
+ assertAuthRunBrowser(resolved.run.browserSessionId, browserSessionId);
1530
+ if (resolved.run.controllerGeneration !== binding.controllerGeneration) {
1531
+ throw new InteractionResourceConflictError(
1532
+ "Hosted login belongs to a stale browser controller",
1533
+ );
1534
+ }
1535
+ const providerResult = await sessionClient.externalAuth(
1536
+ BrowserExternalAuthCommand.parse({
1537
+ browserSessionId,
1538
+ controllerGeneration: binding.controllerGeneration,
1539
+ operationId: request.operationId,
1540
+ authRunId,
1541
+ adapterId: resolved.authority.adapterId,
1542
+ connectionId: resolved.authority.connectionId,
1543
+ action: "interactive",
1544
+ }),
1545
+ );
1546
+ if (
1547
+ providerResult.state !== "needs_human" ||
1548
+ !providerResult.externalAction ||
1549
+ !providerResult.interactiveUrl
1550
+ ) {
1551
+ throw new InteractionResourceStateError(
1552
+ "Hosted login is no longer waiting for human input",
1553
+ );
1554
+ }
1555
+ return ExternalAuthInteractiveResponse.parse({
1556
+ authRunId,
1557
+ url: providerResult.interactiveUrl,
1558
+ expiresAt: providerResult.externalAction.expiresAt,
1559
+ });
1560
+ },
1561
+ );
1562
+ return context.json(ExternalAuthInteractiveResponse.parse(result));
1563
+ },
1564
+ );
1565
+
1566
+ app.post(
1567
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/verify",
1568
+ async (context) => {
1569
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1570
+ context,
1571
+ "sessions:control",
1572
+ );
1573
+ const authRunId = requireUuidParam(context, "authRunId");
1574
+ const request = await parseJsonBody(context, VerifyAuthRunRequest);
1575
+ const startedAtMs = performance.now();
1576
+ const result = await withActiveBrowserController(
1577
+ context,
1578
+ grant,
1579
+ workspaceId,
1580
+ browserSessionId,
1581
+ "session.control",
1582
+ "browser.control",
1583
+ async ({ sessionClient, binding }) => {
1584
+ const run = await getAuthRun(deps.db, {
1585
+ accountId: grant.accountId,
1586
+ workspaceId,
1587
+ authRunId,
1588
+ });
1589
+ assertAuthRunBrowser(run.browserSessionId, browserSessionId);
1590
+ const observation = await sessionClient.observe(run.targetId);
1591
+ return await verifyAuthRun(deps.db, {
1592
+ accountId: grant.accountId,
1593
+ workspaceId,
1594
+ actorSubjectId: grant.subjectId,
1595
+ authRunId,
1596
+ controllerGeneration: binding.controllerGeneration,
1597
+ targetId: observation.target.id,
1598
+ targetGeneration: observation.target.targetGeneration,
1599
+ documentGeneration: observation.target.documentGeneration,
1600
+ url: observation.target.url,
1601
+ ...request,
1602
+ });
1603
+ },
1604
+ );
1605
+ observeAuthMutation(deps.observability, startedAtMs, result);
1606
+ return context.json(result);
1607
+ },
1608
+ );
1609
+
1610
+ app.get(
1611
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/operations/:operationId",
1612
+ async (context) => {
1613
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1614
+ context,
1615
+ "sessions:read",
1616
+ );
1617
+ const operationId = requireUuidParam(context, "operationId");
1618
+ const result = await withActiveBrowserController(
1619
+ context,
1620
+ grant,
1621
+ workspaceId,
1622
+ browserSessionId,
1623
+ "session.read",
1624
+ "browser.read",
1625
+ async ({ sessionClient }) => await sessionClient.receipt(operationId),
1626
+ );
1627
+ return context.json(result);
1628
+ },
1629
+ );
1630
+
1631
+ app.get(
1632
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/diagnostics",
1633
+ async (context) => {
1634
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1635
+ context,
1636
+ "sessions:read",
1637
+ );
1638
+ const targetId = requireOpaqueParam(context, "targetId");
1639
+ const kinds = diagnosticKinds(context.req.query("kinds"));
1640
+ const afterSequence = optionalBoundedInteger(context.req.query("after"), 0, 2 ** 53 - 1);
1641
+ const limit = optionalBoundedInteger(context.req.query("limit"), 1, 1_000);
1642
+ const result = await withActiveBrowserController(
1643
+ context,
1644
+ grant,
1645
+ workspaceId,
1646
+ browserSessionId,
1647
+ "session.read",
1648
+ "browser.read",
1649
+ async ({ sessionClient }) =>
1650
+ await sessionClient.diagnostics(targetId, {
1651
+ ...(kinds ? { kinds } : {}),
1652
+ ...(afterSequence === null ? {} : { afterSequence }),
1653
+ ...(limit === null ? {} : { limit }),
1654
+ }),
1655
+ );
1656
+ return context.json(result);
1657
+ },
1658
+ );
1659
+
1660
+ app.post(
1661
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/attachments",
1662
+ async (context) => {
1663
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1664
+ context,
1665
+ "stream:view",
1666
+ );
1667
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
1668
+ const request = await parseJsonBody(context, BrowserSessionAttachmentRequest);
1669
+ const result = await withActiveBrowserController(
1670
+ context,
1671
+ grant,
1672
+ workspaceId,
1673
+ browserSessionId,
1674
+ "session.viewer.read",
1675
+ "browser.attach",
1676
+ async ({ client, sessionClient, record, binding, placement }) => {
1677
+ if (origin) await client.addAllowedOrigins([origin]);
1678
+ const targets = await sessionClient.listTargets();
1679
+ if (!targets.some((target) => target.id === request.targetId)) {
1680
+ throw new HTTPException(404, { message: "browser target does not exist" });
1681
+ }
1682
+ const grantId = randomUUID();
1683
+ const expiresAt = new Date(Date.now() + request.expiresInSeconds * 1_000).toISOString();
1684
+ const rootSecret = browserAuthorityRoot(deps);
1685
+ const relaySecret = placement.session.openBrowserFrames
1686
+ ? resolveStreamTokenSecret(deps.settings)
1687
+ : null;
1688
+ if (placement.session.openBrowserFrames && !relaySecret) {
1689
+ throw new BrowserControlUnsupportedError(
1690
+ "browser frame relay authority is unavailable",
1691
+ );
1692
+ }
1693
+ const token = deriveBrowserViewGrantToken({
1694
+ rootSecret,
1695
+ accountId: grant.accountId,
1696
+ workspaceId,
1697
+ placement: record.session.placement,
1698
+ placementInstanceId: placement.placementInstanceId,
1699
+ browserSessionId,
1700
+ controllerGeneration: binding.controllerGeneration,
1701
+ tokenGeneration: record.tokenGeneration,
1702
+ grantId,
1703
+ expiresAt,
1704
+ });
1705
+ const reference = {
1706
+ browserSessionId,
1707
+ controllerGeneration: binding.controllerGeneration,
1708
+ };
1709
+ await client.createViewGrant(reference, {
1710
+ grantId,
1711
+ token,
1712
+ expiresAt,
1713
+ });
1714
+ const relayed = await client.openRelayedFrameStream({
1715
+ reference,
1716
+ targetId: request.targetId,
1717
+ viewToken: token,
1718
+ expiresAt,
1719
+ ...(request.stream ? { stream: request.stream } : {}),
1720
+ });
1721
+ const stream = relayed
1722
+ ? await (async () => {
1723
+ const relayToken = await mintStreamToken(relaySecret!, {
1724
+ workspaceId,
1725
+ sessionId: record.sourceSessionId,
1726
+ viewerId: grantId,
1727
+ leaseEpoch: record.tokenGeneration,
1728
+ port: relayed.channel.port,
1729
+ ttlSeconds: request.expiresInSeconds,
1730
+ });
1731
+ return {
1732
+ kind: "relay" as const,
1733
+ url: buildStreamUrl(relayed.endpoint),
1734
+ token: relayToken,
1735
+ channel: {
1736
+ channelId: relayed.channel.channelId,
1737
+ workspaceId: relayed.channel.workspaceId,
1738
+ agentId: relayed.channel.agentId,
1739
+ kind: 3 as const,
1740
+ port: relayed.channel.port,
1741
+ },
1742
+ };
1743
+ })()
1744
+ : {
1745
+ kind: "direct_websocket" as const,
1746
+ url: await client.frameStreamUrl(reference, request.targetId, request.stream),
1747
+ protocols: [
1748
+ BROWSER_CONTROL_WEBSOCKET_PROTOCOL,
1749
+ `${BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX}${token}`,
1750
+ ],
1751
+ };
1752
+ return BrowserSessionAttachment.parse({
1753
+ browserSessionId,
1754
+ controllerGeneration: binding.controllerGeneration,
1755
+ targetId: request.targetId,
1756
+ stream,
1757
+ expiresAt,
1758
+ });
1759
+ },
1760
+ );
1761
+ return context.json(result, 201);
1762
+ },
1763
+ );
1764
+
1765
+ app.post(
1766
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/heartbeat",
1767
+ async (context) => {
1768
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1769
+ context,
1770
+ "sessions:read",
1771
+ );
1772
+ await parseEmptyJsonBody(context);
1773
+ const result = await withActiveBrowserController(
1774
+ context,
1775
+ grant,
1776
+ workspaceId,
1777
+ browserSessionId,
1778
+ "session.read",
1779
+ "browser.read",
1780
+ async ({ sessionClient, binding }) => {
1781
+ await sessionClient.listTargets();
1782
+ return BrowserSessionHeartbeatResponse.parse({
1783
+ browserSessionId,
1784
+ controllerGeneration: binding.controllerGeneration,
1785
+ alive: true,
1786
+ });
1787
+ },
1788
+ false,
1789
+ );
1790
+ return context.json(result);
1791
+ },
1792
+ );
1793
+
1794
+ app.post(
1795
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/revisions",
1796
+ async (context) => {
1797
+ const workspaceId = context.req.param("workspaceId") ?? "";
1798
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
1799
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
1800
+ const request = await parseJsonBody(context, PublishBrowserRevisionRequest);
1801
+ const startedAtMs = performance.now();
1802
+ try {
1803
+ const record = await getBrowserSessionControlRecord(deps.db, {
1804
+ accountId: grant.accountId,
1805
+ workspaceId,
1806
+ browserSessionId,
1807
+ });
1808
+ await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.control");
1809
+ const publicationInput = {
1810
+ accountId: grant.accountId,
1811
+ workspaceId,
1812
+ operationId: request.operationId,
1813
+ browserSessionId,
1814
+ identityId: request.identityId,
1815
+ expectedHeadGeneration: request.expectedHeadGeneration,
1816
+ advanceDefault: request.advanceDefault,
1817
+ actorSubjectId: grant.subjectId,
1818
+ };
1819
+ const prepared = await prepareBrowserRevisionPublication(deps.db, publicationInput);
1820
+ if (prepared.kind === "completed") {
1821
+ const parsed = PublishBrowserRevisionResponse.parse(prepared.response);
1822
+ observeBrowserRevisionPublication(deps.observability, startedAtMs, parsed);
1823
+ return context.json(parsed, 200);
1824
+ }
1825
+ const objectStorage = deps.objectStorage;
1826
+ if (!objectStorage) {
1827
+ throw new HTTPException(503, {
1828
+ message: "browser state storage is not configured",
1829
+ });
1830
+ }
1831
+
1832
+ const response = await withActiveBrowserController(
1833
+ context,
1834
+ grant,
1835
+ workspaceId,
1836
+ browserSessionId,
1837
+ "session.control",
1838
+ "browser.control",
1839
+ async ({ client, binding, record: activeRecord }) => {
1840
+ if (
1841
+ prepared.browserSessionId !== browserSessionId ||
1842
+ prepared.controllerGeneration !== binding.controllerGeneration
1843
+ ) {
1844
+ throw new BrowserIdentityConflictError(
1845
+ "BrowserRevision publication belongs to a stale controller",
1846
+ );
1847
+ }
1848
+ const rootKey = requireBrowserStateRoot(deps);
1849
+ const objectKey = browserStateObjectKey(workspaceId, request.operationId);
1850
+ const dataKey = deriveBrowserStateDataKey(rootKey, {
1851
+ accountId: grant.accountId,
1852
+ workspaceId,
1853
+ browserSessionId,
1854
+ operationId: request.operationId,
1855
+ objectKey,
1856
+ });
1857
+ const aad = browserStateArtifactAad({
1858
+ accountId: grant.accountId,
1859
+ workspaceId,
1860
+ objectKey,
1861
+ });
1862
+ try {
1863
+ const dispatched = await dispatchBrowserRevisionPublication(deps.db, {
1864
+ ...publicationInput,
1865
+ controllerGeneration: binding.controllerGeneration,
1866
+ stateUpload: {
1867
+ objectKey,
1868
+ cleanupAfter: new Date(Date.now() + BROWSER_STATE_UPLOAD_CLEANUP_GRACE_MS),
1869
+ },
1870
+ });
1871
+ if (dispatched.kind === "completed") return dispatched.response;
1872
+ const signed = await objectStorage.createPutUrl({
1873
+ key: objectKey,
1874
+ contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
1875
+ audience: signedUrlAudienceForPlacement(activeRecord.session.placement),
1876
+ });
1877
+
1878
+ let receipt;
1879
+ try {
1880
+ receipt = await client.captureState({
1881
+ browserSessionId,
1882
+ controllerGeneration: binding.controllerGeneration,
1883
+ operationId: request.operationId,
1884
+ objectKey,
1885
+ afterCapture: "restart",
1886
+ dataKey,
1887
+ aad,
1888
+ upload: {
1889
+ url: signed.url,
1890
+ requiredHeaders: signed.requiredHeaders,
1891
+ expiresAt: signed.expiresAt.toISOString(),
1892
+ },
1893
+ });
1894
+ } catch (error) {
1895
+ await settleBrowserRevisionCaptureFailure(
1896
+ deps,
1897
+ {
1898
+ accountId: grant.accountId,
1899
+ workspaceId,
1900
+ operationId: request.operationId,
1901
+ browserSessionId,
1902
+ controllerGeneration: binding.controllerGeneration,
1903
+ },
1904
+ error,
1905
+ );
1906
+ throw error;
1907
+ }
1908
+
1909
+ const materialization = browserRevisionMaterialization(
1910
+ receipt.manifest,
1911
+ activeRecord.session.placement,
1912
+ );
1913
+ const encryptedDataKey = wrapBrowserStateDataKey(rootKey, dataKey, {
1914
+ accountId: grant.accountId,
1915
+ workspaceId,
1916
+ objectKey,
1917
+ artifactDigest: receipt.artifactDigest,
1918
+ contentDigest: receipt.contentDigest,
1919
+ });
1920
+ return await commitBrowserRevisionPublication(deps.db, {
1921
+ ...publicationInput,
1922
+ controllerGeneration: binding.controllerGeneration,
1923
+ manifestDigest: browserStateManifestDigest(receipt.manifest),
1924
+ artifacts: [
1925
+ {
1926
+ kind: "chromium_profile",
1927
+ format: receipt.format,
1928
+ artifactDigest: receipt.artifactDigest,
1929
+ contentDigest: receipt.contentDigest,
1930
+ manifestDigest: browserStateManifestDigest(receipt.manifest),
1931
+ objectKey,
1932
+ encryptedDataKey,
1933
+ sizeBytes: receipt.sizeBytes,
1934
+ materialization,
1935
+ },
1936
+ ],
1937
+ });
1938
+ } finally {
1939
+ dataKey.fill(0);
1940
+ rootKey.fill(0);
1941
+ aad.fill(0);
1942
+ }
1943
+ },
1944
+ );
1945
+ const parsed = PublishBrowserRevisionResponse.parse(response);
1946
+ observeBrowserRevisionPublication(deps.observability, startedAtMs, parsed);
1947
+ return context.json(parsed, parsed.replayed ? 200 : 201);
1948
+ } catch (error) {
1949
+ throw browserRouteError(error);
1950
+ }
1951
+ },
1952
+ );
1953
+
1954
+ app.post(
1955
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/suspend",
1956
+ async (context) => {
1957
+ const workspaceId = context.req.param("workspaceId");
1958
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
1959
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
1960
+ const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
1961
+ const startedAtMs = performance.now();
1962
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
1963
+ try {
1964
+ const before = await getBrowserSessionControlRecord(deps.db, {
1965
+ accountId: grant.accountId,
1966
+ workspaceId,
1967
+ browserSessionId,
1968
+ });
1969
+ await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
1970
+ if (!before.session.capabilities.privateCheckpoint) {
1971
+ throw new BrowserControlUnsupportedError(
1972
+ "this browser placement does not support private checkpoint suspension",
1973
+ );
1974
+ }
1975
+ browserAuthorityRoot(deps);
1976
+ if (!deps.objectStorage) {
1977
+ throw new HTTPException(503, {
1978
+ message: "browser state storage is not configured",
1979
+ });
1980
+ }
1981
+ const prepared = await prepareBrowserSessionSuspend(deps.db, {
1982
+ accountId: grant.accountId,
1983
+ workspaceId,
1984
+ browserSessionId,
1985
+ operationId: request.operationId,
1986
+ actorSubjectId: grant.subjectId,
1987
+ });
1988
+ if (isTerminalOperation(prepared.operation.state)) {
1989
+ if (prepared.operation.state === "completed") {
1990
+ await finishSuspendedBrowserControllerCleanup(
1991
+ context,
1992
+ grant,
1993
+ workspaceId,
1994
+ browserSessionId,
1995
+ );
1996
+ const parsed = BrowserSessionMutationResponse.parse({
1997
+ session: (
1998
+ await getBrowserSessionControlRecord(deps.db, {
1999
+ accountId: grant.accountId,
2000
+ workspaceId,
2001
+ browserSessionId,
2002
+ })
2003
+ ).session,
2004
+ operation: prepared.operation,
2005
+ });
2006
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2007
+ return context.json(parsed, 200);
2008
+ }
2009
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2010
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2011
+ return context.json(parsed, 200);
2012
+ }
2013
+
2014
+ const record = await getBrowserSessionControlRecord(deps.db, {
2015
+ accountId: grant.accountId,
2016
+ workspaceId,
2017
+ browserSessionId,
2018
+ operationId: request.operationId,
2019
+ });
2020
+ const binding = record.session.controller;
2021
+ if (!binding) {
2022
+ throw new BrowserSessionStateError("BrowserSession suspension controller is absent");
2023
+ }
2024
+ const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
2025
+ const response = await withBrowserPlacement(
2026
+ sourceSession,
2027
+ grant,
2028
+ record.session.placement,
2029
+ binding.placementInstanceId,
2030
+ "browser.suspend",
2031
+ context.req.raw.signal,
2032
+ async (placement) => {
2033
+ const objectStorage = deps.objectStorage!;
2034
+ const rootKey = requireBrowserStateRoot(deps);
2035
+ const objectKey = browserStateObjectKey(workspaceId, request.operationId);
2036
+ const dataKey = deriveBrowserStateDataKey(rootKey, {
2037
+ accountId: grant.accountId,
2038
+ workspaceId,
2039
+ browserSessionId,
2040
+ operationId: request.operationId,
2041
+ objectKey,
2042
+ });
2043
+ const aad = browserStateArtifactAad({
2044
+ accountId: grant.accountId,
2045
+ workspaceId,
2046
+ objectKey,
2047
+ });
2048
+ try {
2049
+ await dispatchBrowserSessionOperation(deps.db, {
2050
+ accountId: grant.accountId,
2051
+ workspaceId,
2052
+ operationId: request.operationId,
2053
+ browserSessionId,
2054
+ controllerGeneration: binding.controllerGeneration,
2055
+ stateUpload: {
2056
+ objectKey,
2057
+ cleanupAfter: new Date(Date.now() + BROWSER_STATE_UPLOAD_CLEANUP_GRACE_MS),
2058
+ },
2059
+ });
2060
+ const signed = await objectStorage.createPutUrl({
2061
+ key: objectKey,
2062
+ contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
2063
+ audience: signedUrlAudienceForPlacement(record.session.placement),
2064
+ });
2065
+ const client = await provisionController(deps, grant, record, placement, origin);
2066
+ let receipt: PlacementBrowserStateCaptureReceipt;
2067
+ try {
2068
+ receipt = await withInteractionHolderHeartbeat(
2069
+ deps,
2070
+ {
2071
+ grant,
2072
+ workspaceId,
2073
+ sandboxGroupId: record.controllerHostSandboxGroupId,
2074
+ holderId: interactionHolderId(browserSessionId),
2075
+ operationId: request.operationId,
2076
+ resourceId: browserSessionId,
2077
+ controllerGeneration: binding.controllerGeneration,
2078
+ },
2079
+ async () =>
2080
+ await client.captureState({
2081
+ browserSessionId,
2082
+ controllerGeneration: binding.controllerGeneration,
2083
+ operationId: request.operationId,
2084
+ objectKey,
2085
+ afterCapture: "stop",
2086
+ dataKey,
2087
+ aad,
2088
+ upload: {
2089
+ url: signed.url,
2090
+ requiredHeaders: signed.requiredHeaders,
2091
+ expiresAt: signed.expiresAt.toISOString(),
2092
+ },
2093
+ }),
2094
+ );
2095
+ } catch (error) {
2096
+ const failed = await settleBrowserSessionSuspensionCaptureFailure(
2097
+ deps,
2098
+ {
2099
+ accountId: grant.accountId,
2100
+ workspaceId,
2101
+ operationId: request.operationId,
2102
+ browserSessionId,
2103
+ controllerGeneration: binding.controllerGeneration,
2104
+ },
2105
+ error,
2106
+ );
2107
+ if (failed) return failed;
2108
+ throw error;
2109
+ }
2110
+ const materialization = browserRevisionMaterialization(
2111
+ receipt.manifest,
2112
+ record.session.placement,
2113
+ );
2114
+ const manifestDigest = browserStateManifestDigest(receipt.manifest);
2115
+ const encryptedDataKey = wrapBrowserStateDataKey(rootKey, dataKey, {
2116
+ accountId: grant.accountId,
2117
+ workspaceId,
2118
+ objectKey,
2119
+ artifactDigest: receipt.artifactDigest,
2120
+ contentDigest: receipt.contentDigest,
2121
+ });
2122
+ const committed = await commitBrowserSessionSuspension(deps.db, {
2123
+ accountId: grant.accountId,
2124
+ workspaceId,
2125
+ operationId: request.operationId,
2126
+ browserSessionId,
2127
+ controllerGeneration: binding.controllerGeneration,
2128
+ artifact: {
2129
+ kind: "chromium_profile",
2130
+ format: receipt.format,
2131
+ artifactDigest: receipt.artifactDigest,
2132
+ contentDigest: receipt.contentDigest,
2133
+ manifestDigest,
2134
+ objectKey,
2135
+ encryptedDataKey,
2136
+ sizeBytes: receipt.sizeBytes,
2137
+ materialization,
2138
+ },
2139
+ });
2140
+ await endCapturedBrowserController(client, browserSessionId, binding);
2141
+ await clearSuspendedBrowserSessionController(deps.db, {
2142
+ accountId: grant.accountId,
2143
+ workspaceId,
2144
+ browserSessionId,
2145
+ expectedControllerGeneration: binding.controllerGeneration,
2146
+ });
2147
+ await releaseInteractionHolder(
2148
+ deps,
2149
+ grant,
2150
+ workspaceId,
2151
+ browserSessionId,
2152
+ record.controllerHostSandboxGroupId,
2153
+ );
2154
+ return BrowserSessionMutationResponse.parse({
2155
+ session: (
2156
+ await getBrowserSessionControlRecord(deps.db, {
2157
+ accountId: grant.accountId,
2158
+ workspaceId,
2159
+ browserSessionId,
2160
+ })
2161
+ ).session,
2162
+ operation: committed.operation,
2163
+ });
2164
+ } finally {
2165
+ dataKey.fill(0);
2166
+ rootKey.fill(0);
2167
+ aad.fill(0);
2168
+ }
2169
+ },
2170
+ );
2171
+ const parsed = BrowserSessionMutationResponse.parse(response);
2172
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2173
+ return context.json(parsed, 200);
2174
+ } catch (error) {
2175
+ throw browserRouteError(error);
2176
+ }
2177
+ },
2178
+ );
2179
+
2180
+ app.post(
2181
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/resume",
2182
+ async (context) => {
2183
+ const workspaceId = context.req.param("workspaceId");
2184
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
2185
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
2186
+ const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2187
+ const startedAtMs = performance.now();
2188
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
2189
+ let restore: RestorePlacementBrowserStateInput | null = null;
2190
+ try {
2191
+ let before = await getBrowserSessionControlRecord(deps.db, {
2192
+ accountId: grant.accountId,
2193
+ workspaceId,
2194
+ browserSessionId,
2195
+ });
2196
+ await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
2197
+ if (before.session.lifecycle === "suspended" && before.session.controller) {
2198
+ await finishSuspendedBrowserControllerCleanup(
2199
+ context,
2200
+ grant,
2201
+ workspaceId,
2202
+ browserSessionId,
2203
+ );
2204
+ before = await getBrowserSessionControlRecord(deps.db, {
2205
+ accountId: grant.accountId,
2206
+ workspaceId,
2207
+ browserSessionId,
2208
+ });
2209
+ }
2210
+ const authority = browserAuthorityRoot(deps);
2211
+ const prepared = await prepareBrowserSessionResume(deps.db, {
2212
+ accountId: grant.accountId,
2213
+ workspaceId,
2214
+ browserSessionId,
2215
+ operationId: request.operationId,
2216
+ actorSubjectId: grant.subjectId,
2217
+ });
2218
+ if (isTerminalOperation(prepared.operation.state)) {
2219
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2220
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2221
+ return context.json(parsed, 200);
2222
+ }
2223
+
2224
+ const privateCheckpoint = await getBrowserPrivateCheckpointAuthority(deps.db, {
2225
+ accountId: grant.accountId,
2226
+ workspaceId,
2227
+ browserSessionId,
2228
+ });
2229
+ const revisionAuthority = privateCheckpoint
2230
+ ? null
2231
+ : await requireBrowserRevisionRestoreAuthority(deps, grant, prepared.session);
2232
+ try {
2233
+ restore = privateCheckpoint
2234
+ ? await prepareBrowserPrivateCheckpointRestore(
2235
+ deps,
2236
+ grant,
2237
+ prepared.session,
2238
+ privateCheckpoint,
2239
+ )
2240
+ : await prepareBrowserStateRestore(
2241
+ deps,
2242
+ grant,
2243
+ prepared.session,
2244
+ prepared.session.placement,
2245
+ revisionAuthority,
2246
+ );
2247
+ } catch (error) {
2248
+ await failBrowserSessionResumePreparation(deps.db, {
2249
+ accountId: grant.accountId,
2250
+ workspaceId,
2251
+ operationId: request.operationId,
2252
+ browserSessionId,
2253
+ error: interactionFailure(error),
2254
+ });
2255
+ throw error;
2256
+ }
2257
+ if (!restore) {
2258
+ throw new BrowserSessionStateError("BrowserSession has no restorable durable state");
2259
+ }
2260
+
2261
+ const sourceSession = await requireSourceSession(deps, workspaceId, before.sourceSessionId);
2262
+ const response = await withBrowserPlacement(
2263
+ sourceSession,
2264
+ grant,
2265
+ prepared.session.placement,
2266
+ null,
2267
+ "browser.resume",
2268
+ context.req.raw.signal,
2269
+ async (placement) => {
2270
+ const networkRoute = await resolveBrowserNetworkRouteLaunch({
2271
+ deps,
2272
+ grant,
2273
+ workspaceId,
2274
+ browserSessionId,
2275
+ operationId: request.operationId,
2276
+ rootSecret: authority,
2277
+ placement: placement.placement,
2278
+ });
2279
+ const interactionHeld = await ensureInteractionHolder(
2280
+ deps,
2281
+ grant,
2282
+ sourceSession,
2283
+ browserSessionId,
2284
+ placement,
2285
+ context.req.raw.signal,
2286
+ );
2287
+ const record = await ensureDispatchedGeneration(
2288
+ deps,
2289
+ grant,
2290
+ workspaceId,
2291
+ browserSessionId,
2292
+ request.operationId,
2293
+ placement.placementInstanceId,
2294
+ ).catch(async (error: unknown) => {
2295
+ if (interactionHeld) {
2296
+ await releaseInteractionHolder(
2297
+ deps,
2298
+ grant,
2299
+ workspaceId,
2300
+ browserSessionId,
2301
+ placement.controllerHostSandboxGroupId,
2302
+ ).catch(() => undefined);
2303
+ }
2304
+ throw error;
2305
+ });
2306
+ const controllerGeneration = requireOperationGeneration(record);
2307
+ const linkedComputer = await requireLinkedComputerBinding(
2308
+ deps,
2309
+ grant,
2310
+ prepared.session,
2311
+ placement,
2312
+ );
2313
+ const adminToken = deriveBrowserControllerAdminToken({
2314
+ rootSecret: authority,
2315
+ accountId: grant.accountId,
2316
+ workspaceId,
2317
+ placement: placement.placement,
2318
+ placementInstanceId: placement.placementInstanceId,
2319
+ });
2320
+ const tokens = deriveBrowserSessionControllerTokens({
2321
+ rootSecret: authority,
2322
+ accountId: grant.accountId,
2323
+ workspaceId,
2324
+ browserSessionId,
2325
+ placement: placement.placement,
2326
+ placementInstanceId: placement.placementInstanceId,
2327
+ controllerGeneration,
2328
+ tokenGeneration: record.tokenGeneration,
2329
+ });
2330
+ const { client } = await provisionBrowserControlClient(placement.session, {
2331
+ adminToken,
2332
+ nativeAuthority: nativeBrowserControllerAuthority(workspaceId, placement),
2333
+ ...(origin ? { allowedOrigins: [origin] } : {}),
2334
+ });
2335
+ try {
2336
+ await withInteractionHolderHeartbeat(
2337
+ deps,
2338
+ {
2339
+ grant,
2340
+ workspaceId,
2341
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
2342
+ holderId: interactionHolderId(browserSessionId),
2343
+ operationId: request.operationId,
2344
+ resourceId: browserSessionId,
2345
+ controllerGeneration,
2346
+ },
2347
+ async () =>
2348
+ await client.createSession({
2349
+ browserSessionId,
2350
+ controllerGeneration,
2351
+ tokenGeneration: record.tokenGeneration,
2352
+ ...tokens,
2353
+ headed: !prepared.session.headless,
2354
+ transport: browserRuntimeTransport(prepared.session, placement.transport),
2355
+ ...(linkedComputer ? { linkedComputer } : {}),
2356
+ ...(networkRoute ? { networkRoute } : {}),
2357
+ restore: restore!,
2358
+ }),
2359
+ );
2360
+ } catch (error) {
2361
+ if (!isDefiniteBrowserControllerFailure(error)) throw error;
2362
+ try {
2363
+ await client.endSession(
2364
+ { browserSessionId, controllerGeneration },
2365
+ { removeState: true },
2366
+ );
2367
+ } catch (cleanupError) {
2368
+ if (
2369
+ !(
2370
+ cleanupError instanceof BrowserControlRequestError &&
2371
+ cleanupError.status === 404
2372
+ )
2373
+ ) {
2374
+ throw cleanupError;
2375
+ }
2376
+ }
2377
+ const failed = await failBrowserSessionResume(deps.db, {
2378
+ accountId: grant.accountId,
2379
+ workspaceId,
2380
+ operationId: request.operationId,
2381
+ browserSessionId,
2382
+ controllerGeneration,
2383
+ error: interactionFailure(error),
2384
+ });
2385
+ if (interactionHeld) {
2386
+ await releaseInteractionHolder(
2387
+ deps,
2388
+ grant,
2389
+ workspaceId,
2390
+ browserSessionId,
2391
+ placement.controllerHostSandboxGroupId,
2392
+ ).catch(() => undefined);
2393
+ }
2394
+ return failed;
2395
+ }
2396
+ return await activateBrowserSession(deps.db, {
2397
+ accountId: grant.accountId,
2398
+ workspaceId,
2399
+ operationId: request.operationId,
2400
+ browserSessionId,
2401
+ controller: {
2402
+ controllerId: "opengeni-browserd",
2403
+ controllerGeneration,
2404
+ placementInstanceId: placement.placementInstanceId,
2405
+ },
2406
+ engineVersion: null,
2407
+ });
2408
+ },
2409
+ );
2410
+ const parsed = BrowserSessionMutationResponse.parse(response);
2411
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2412
+ return context.json(parsed, 200);
2413
+ } catch (error) {
2414
+ throw browserRouteError(error);
2415
+ } finally {
2416
+ restore?.dataKey.fill(0);
2417
+ restore?.aad.fill(0);
2418
+ }
2419
+ },
2420
+ );
2421
+
2422
+ app.post(
2423
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/end",
2424
+ async (context) => {
2425
+ const workspaceId = context.req.param("workspaceId");
2426
+ const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
2427
+ const browserSessionId = requireUuidParam(context, "browserSessionId");
2428
+ const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2429
+ const startedAtMs = performance.now();
2430
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
2431
+ try {
2432
+ const before = await getBrowserSessionControlRecord(deps.db, {
2433
+ accountId: grant.accountId,
2434
+ workspaceId,
2435
+ browserSessionId,
2436
+ });
2437
+ await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
2438
+ if (before.session.controller) browserAuthorityRoot(deps);
2439
+ const prepared = await prepareBrowserSessionEnd(deps.db, {
2440
+ accountId: grant.accountId,
2441
+ workspaceId,
2442
+ browserSessionId,
2443
+ operationId: request.operationId,
2444
+ actorSubjectId: grant.subjectId,
2445
+ });
2446
+ if (isTerminalOperation(prepared.operation.state)) {
2447
+ if (prepared.operation.state === "completed") {
2448
+ await releaseInteractionHolder(
2449
+ deps,
2450
+ grant,
2451
+ workspaceId,
2452
+ browserSessionId,
2453
+ before.controllerHostSandboxGroupId,
2454
+ ).catch(() => undefined);
2455
+ }
2456
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2457
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2458
+ return context.json(parsed, 200);
2459
+ }
2460
+
2461
+ const record = await getBrowserSessionControlRecord(deps.db, {
2462
+ accountId: grant.accountId,
2463
+ workspaceId,
2464
+ browserSessionId,
2465
+ operationId: request.operationId,
2466
+ });
2467
+ const binding = record.session.controller;
2468
+ if (!binding) {
2469
+ const completed = await completeBrowserSessionEnd(deps.db, {
2470
+ accountId: grant.accountId,
2471
+ workspaceId,
2472
+ operationId: request.operationId,
2473
+ browserSessionId,
2474
+ expectedControllerGeneration: null,
2475
+ });
2476
+ await releaseInteractionHolder(
2477
+ deps,
2478
+ grant,
2479
+ workspaceId,
2480
+ browserSessionId,
2481
+ record.controllerHostSandboxGroupId,
2482
+ ).catch(() => undefined);
2483
+ const parsed = BrowserSessionMutationResponse.parse(completed);
2484
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2485
+ return context.json(parsed, 200);
2486
+ }
2487
+ const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
2488
+ const response = await withBrowserPlacement(
2489
+ sourceSession,
2490
+ grant,
2491
+ record.session.placement,
2492
+ binding.placementInstanceId,
2493
+ "browser.end",
2494
+ context.req.raw.signal,
2495
+ async (placement) => {
2496
+ await dispatchBrowserSessionOperation(deps.db, {
2497
+ accountId: grant.accountId,
2498
+ workspaceId,
2499
+ operationId: request.operationId,
2500
+ browserSessionId,
2501
+ controllerGeneration: binding.controllerGeneration,
2502
+ });
2503
+ const client = await provisionController(deps, grant, record, placement, origin);
2504
+ try {
2505
+ await withInteractionHolderHeartbeat(
2506
+ deps,
2507
+ {
2508
+ grant,
2509
+ workspaceId,
2510
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
2511
+ holderId: interactionHolderId(browserSessionId),
2512
+ operationId: request.operationId,
2513
+ resourceId: browserSessionId,
2514
+ controllerGeneration: binding.controllerGeneration,
2515
+ },
2516
+ async () =>
2517
+ await client.endSession(
2518
+ {
2519
+ browserSessionId,
2520
+ controllerGeneration: binding.controllerGeneration,
2521
+ },
2522
+ { removeState: true },
2523
+ ),
2524
+ );
2525
+ } catch (error) {
2526
+ // Physical absence already satisfies an exact end. A stale live
2527
+ // generation is 409 and transport uncertainty remains retryable.
2528
+ if (!(error instanceof BrowserControlRequestError && error.status === 404)) {
2529
+ throw error;
2530
+ }
2531
+ }
2532
+ const completed = await completeBrowserSessionEnd(deps.db, {
2533
+ accountId: grant.accountId,
2534
+ workspaceId,
2535
+ operationId: request.operationId,
2536
+ browserSessionId,
2537
+ expectedControllerGeneration: binding.controllerGeneration,
2538
+ });
2539
+ await releaseInteractionHolder(
2540
+ deps,
2541
+ grant,
2542
+ workspaceId,
2543
+ browserSessionId,
2544
+ placement.controllerHostSandboxGroupId,
2545
+ ).catch(() => undefined);
2546
+ return completed;
2547
+ },
2548
+ );
2549
+ const parsed = BrowserSessionMutationResponse.parse(response);
2550
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2551
+ return context.json(parsed, 200);
2552
+ } catch (error) {
2553
+ throw browserRouteError(error);
2554
+ }
2555
+ },
2556
+ );
2557
+
2558
+ async function withBrowserPlacement<T>(
2559
+ sourceSession: Session,
2560
+ grant: AccessGrant,
2561
+ expectedPlacement: InteractionPlacement | null,
2562
+ expectedPlacementInstanceId: string | null,
2563
+ operation: ChannelAOperation,
2564
+ waitSignal: AbortSignal,
2565
+ callback: (placement: BrowserPlacement) => Promise<T>,
2566
+ ): Promise<T> {
2567
+ if (expectedPlacement?.kind === "attached_device") {
2568
+ waitSignal.throwIfAborted();
2569
+ const device = await getAttachedBrowserDevice(deps.db, {
2570
+ accountId: grant.accountId,
2571
+ workspaceId: sourceSession.workspaceId,
2572
+ deviceId: expectedPlacement.deviceId,
2573
+ });
2574
+ if (device.state !== "connected") {
2575
+ throw new BrowserSessionStateError("Attached browser is disconnected");
2576
+ }
2577
+ const enrollment = await getEnrollment(
2578
+ deps.db,
2579
+ sourceSession.workspaceId,
2580
+ device.enrollmentId,
2581
+ );
2582
+ if (!enrollment || enrollment.status !== "active") {
2583
+ throw new BrowserSessionStateError("Attached browser machine is unavailable");
2584
+ }
2585
+ assertPlacementInstance(expectedPlacementInstanceId, device.connectionGeneration);
2586
+ const built = await buildSelfhostedBackendSession({
2587
+ workspaceId: sourceSession.workspaceId,
2588
+ agentId: device.enrollmentId,
2589
+ relay: relayConfigFromSettings(deps.settings),
2590
+ controlRpcFactory: () => new NatsControlRpc(async () => deps.bus.getRequestConnection()),
2591
+ // Browser-profile generation is the physical controller fence. Epoch 0
2592
+ // deliberately addresses the enrollment itself rather than borrowing an
2593
+ // unrelated session routing epoch.
2594
+ epoch: 0,
2595
+ timeoutMs: deps.settings.sandboxSelfhostedControlTimeoutMs,
2596
+ execTimeoutMs: deps.settings.sandboxSelfhostedExecTimeoutMs,
2597
+ ...(deps.settings.agentOpStreamEnabled === true &&
2598
+ enrollment.opStream === true &&
2599
+ deps.bus.getOpStreamConnection
2600
+ ? {
2601
+ opStream: {
2602
+ transport: new NatsOpStreamTransport(
2603
+ async () => deps.bus.getOpStreamConnection?.() ?? null,
2604
+ ),
2605
+ },
2606
+ }
2607
+ : {}),
2608
+ });
2609
+ waitSignal.throwIfAborted();
2610
+ return await callback({
2611
+ placement: expectedPlacement,
2612
+ controllerHostSandboxGroupId: null,
2613
+ placementInstanceId: device.connectionGeneration,
2614
+ session: built.session as unknown as BrowserControlPlacementSession,
2615
+ lease: null,
2616
+ transport: {
2617
+ kind: "attached_chrome",
2618
+ deviceId: device.id,
2619
+ connectionGeneration: device.connectionGeneration,
2620
+ browserName: device.browserName,
2621
+ browserVersion: device.browserVersion,
2622
+ },
2623
+ });
2624
+ }
2625
+ const runWithChannelA =
2626
+ operation === "browser.read" ||
2627
+ operation === "browser.action" ||
2628
+ operation === "browser.control" ||
2629
+ operation === "browser.attach"
2630
+ ? withChannelARead
2631
+ : withChannelA;
2632
+ return await runWithChannelA(
2633
+ channelServices,
2634
+ {
2635
+ accountId: grant.accountId,
2636
+ workspaceId: sourceSession.workspaceId,
2637
+ session: sourceSession,
2638
+ subjectId: grant.subjectId,
2639
+ waitSignal,
2640
+ operation,
2641
+ retryControllerTransport: operation === "browser.read" || operation === "browser.action",
2642
+ },
2643
+ async (handle) => {
2644
+ if (expectedPlacement?.kind === "sandbox_group") {
2645
+ if (
2646
+ sourceSession.sandboxGroupId !== expectedPlacement.sandboxGroupId ||
2647
+ !handle.lease?.instanceId
2648
+ ) {
2649
+ throw new BrowserSessionStateError("BrowserSession home placement is unavailable");
2650
+ }
2651
+ assertPlacementInstance(expectedPlacementInstanceId, handle.lease.instanceId);
2652
+ return await callback({
2653
+ placement: expectedPlacement,
2654
+ controllerHostSandboxGroupId: expectedPlacement.sandboxGroupId,
2655
+ placementInstanceId: handle.lease.instanceId,
2656
+ session: handle.homeSession,
2657
+ lease: handle.lease,
2658
+ transport: { kind: "managed" },
2659
+ });
2660
+ }
2661
+
2662
+ if (expectedPlacement?.kind === "external_provider") {
2663
+ if (!handle.lease?.instanceId) {
2664
+ throw new BrowserSessionStateError(
2665
+ "Remote BrowserSession controller placement is unavailable",
2666
+ );
2667
+ }
2668
+ assertPlacementInstance(expectedPlacementInstanceId, handle.lease.instanceId);
2669
+ return await callback({
2670
+ placement: expectedPlacement,
2671
+ controllerHostSandboxGroupId: sourceSession.sandboxGroupId,
2672
+ placementInstanceId: handle.lease.instanceId,
2673
+ session: handle.homeSession,
2674
+ lease: handle.lease,
2675
+ transport: externalBrowserTransport(deps, expectedPlacement),
2676
+ });
2677
+ }
2678
+
2679
+ const resolved = await handle.routingSession.prime();
2680
+ if (expectedPlacement?.kind === "connected_machine") {
2681
+ if (
2682
+ resolved.kind !== "selfhosted" ||
2683
+ resolved.sandboxId !== expectedPlacement.sandboxId
2684
+ ) {
2685
+ throw new BrowserSessionStateError(
2686
+ "BrowserSession Connected Machine is not the active placement",
2687
+ );
2688
+ }
2689
+ const placementInstanceId = resolved.providerInstanceId ?? expectedPlacement.sandboxId;
2690
+ assertPlacementInstance(expectedPlacementInstanceId, placementInstanceId);
2691
+ return await callback({
2692
+ placement: expectedPlacement,
2693
+ controllerHostSandboxGroupId: null,
2694
+ placementInstanceId,
2695
+ session: resolved.session as unknown as BrowserControlPlacementSession,
2696
+ lease: null,
2697
+ transport: { kind: "managed" },
2698
+ });
2699
+ }
2700
+ if (resolved.sandboxId === null) {
2701
+ if (!handle.lease?.instanceId) {
2702
+ throw new BrowserSessionStateError("BrowserSession home placement is unavailable");
2703
+ }
2704
+ if (
2705
+ resolved.providerInstanceId &&
2706
+ resolved.providerInstanceId !== handle.lease.instanceId
2707
+ ) {
2708
+ throw new BrowserSessionStateError("BrowserSession home placement fence changed");
2709
+ }
2710
+ return await callback({
2711
+ placement: {
2712
+ kind: "sandbox_group",
2713
+ sandboxGroupId: sourceSession.sandboxGroupId,
2714
+ },
2715
+ controllerHostSandboxGroupId: sourceSession.sandboxGroupId,
2716
+ placementInstanceId: handle.lease.instanceId,
2717
+ session: handle.homeSession,
2718
+ lease: handle.lease,
2719
+ transport: { kind: "managed" },
2720
+ });
2721
+ }
2722
+ if (resolved.kind === "selfhosted") {
2723
+ const placement = {
2724
+ kind: "connected_machine" as const,
2725
+ sandboxId: resolved.sandboxId,
2726
+ };
2727
+ return await callback({
2728
+ placement,
2729
+ controllerHostSandboxGroupId: null,
2730
+ placementInstanceId: resolved.providerInstanceId ?? resolved.sandboxId,
2731
+ session: resolved.session as unknown as BrowserControlPlacementSession,
2732
+ lease: null,
2733
+ transport: { kind: "managed" },
2734
+ });
2735
+ }
2736
+ throw new BrowserControlUnsupportedError(
2737
+ "browser creation on a non-home provider sandbox is not supported",
2738
+ );
2739
+ },
2740
+ );
2741
+ }
2742
+
2743
+ async function browserRoutePreamble(
2744
+ context: Context,
2745
+ permission: "sessions:read" | "sessions:control" | "stream:view",
2746
+ ): Promise<{
2747
+ workspaceId: string;
2748
+ grant: AccessGrant;
2749
+ browserSessionId: string;
2750
+ }> {
2751
+ const workspaceId = context.req.param("workspaceId") ?? "";
2752
+ const grant = await requireAccessGrant(context, deps, workspaceId, permission);
2753
+ return {
2754
+ workspaceId,
2755
+ grant,
2756
+ browserSessionId: requireUuidParam(context, "browserSessionId"),
2757
+ };
2758
+ }
2759
+
2760
+ function controllerOnlySession(url: string): BrowserControlPlacementSession {
2761
+ const endpoint = exposedPortEndpointFromUrl(url);
2762
+ return {
2763
+ resolveExposedPort: async (port: number) => {
2764
+ if (port !== BROWSER_CONTROL_PORT) {
2765
+ throw new BrowserControlUnsupportedError(`cached controller cannot expose port ${port}`);
2766
+ }
2767
+ return endpoint;
2768
+ },
2769
+ };
2770
+ }
2771
+
2772
+ async function cachedBrowserPlacement(
2773
+ record: BrowserSessionControlRecord,
2774
+ ): Promise<BrowserPlacement | null> {
2775
+ const binding = record.session.controller;
2776
+ const sandboxGroupId = record.controllerHostSandboxGroupId;
2777
+ if (!binding || !sandboxGroupId) return null;
2778
+ const lease = await readLease(deps.db, record.session.workspaceId, sandboxGroupId);
2779
+ if (
2780
+ !lease ||
2781
+ (lease.liveness !== "warm" && lease.liveness !== "draining") ||
2782
+ lease.instanceId !== binding.placementInstanceId ||
2783
+ !lease.controllerDataPlaneUrl
2784
+ ) {
2785
+ return null;
2786
+ }
2787
+ return {
2788
+ placement: record.session.placement,
2789
+ controllerHostSandboxGroupId: sandboxGroupId,
2790
+ placementInstanceId: binding.placementInstanceId,
2791
+ session: controllerOnlySession(lease.controllerDataPlaneUrl),
2792
+ lease,
2793
+ transport:
2794
+ record.session.placement.kind === "external_provider"
2795
+ ? externalBrowserTransport(deps, record.session.placement)
2796
+ : { kind: "managed" },
2797
+ };
2798
+ }
2799
+
2800
+ async function cacheBrowserControllerPlacement(
2801
+ grant: AccessGrant,
2802
+ workspaceId: string,
2803
+ placement: BrowserPlacement,
2804
+ ): Promise<BrowserPlacement> {
2805
+ const sandboxGroupId = placement.controllerHostSandboxGroupId;
2806
+ if (!sandboxGroupId || !placement.lease?.instanceId || !placement.session.resolveExposedPort) {
2807
+ return placement;
2808
+ }
2809
+ const endpoint = await placement.session.resolveExposedPort(BROWSER_CONTROL_PORT);
2810
+ const url = buildStreamUrl(endpoint);
2811
+ const lease = await recordLeaseControllerDataPlaneUrl(deps.db, {
2812
+ accountId: grant.accountId,
2813
+ workspaceId,
2814
+ sandboxGroupId,
2815
+ expectedEpoch: placement.lease.leaseEpoch,
2816
+ expectedInstanceId: placement.lease.instanceId,
2817
+ controllerDataPlaneUrl: url,
2818
+ });
2819
+ if (!lease) return placement;
2820
+ return { ...placement, session: controllerOnlySession(url), lease };
2821
+ }
2822
+
2823
+ async function withBrowserCreationController<T>(
2824
+ routeDeps: ApiRouteDeps,
2825
+ grant: AccessGrant,
2826
+ workspaceId: string,
2827
+ placement: BrowserPlacement,
2828
+ adminToken: string,
2829
+ origin: string | null,
2830
+ operation: (client: BrowserControlClient) => Promise<T>,
2831
+ ): Promise<T> {
2832
+ const sandboxGroupId = placement.controllerHostSandboxGroupId;
2833
+ const cachedUrl = placement.lease?.controllerDataPlaneUrl;
2834
+ return await withCachedController({
2835
+ cachedUrl: sandboxGroupId ? (cachedUrl ?? null) : null,
2836
+ createCachedClient: (url) =>
2837
+ new BrowserControlClient(controllerOnlySession(url), { adminToken }),
2838
+ prepareCachedClient: async (client) => {
2839
+ if (origin) await client.addAllowedOrigins([origin]);
2840
+ },
2841
+ invalidateCachedUrl: async () => {
2842
+ if (!sandboxGroupId || !placement.lease) return;
2843
+ await recordLeaseControllerDataPlaneUrl(routeDeps.db, {
2844
+ accountId: grant.accountId,
2845
+ workspaceId,
2846
+ sandboxGroupId,
2847
+ expectedEpoch: placement.lease.leaseEpoch,
2848
+ expectedInstanceId: placement.placementInstanceId,
2849
+ controllerDataPlaneUrl: null,
2850
+ });
2851
+ },
2852
+ provisionClient: async () => {
2853
+ const { client } = await provisionBrowserControlClient(placement.session, {
2854
+ adminToken,
2855
+ nativeAuthority: nativeBrowserControllerAuthority(workspaceId, placement),
2856
+ ...(origin ? { allowedOrigins: [origin] } : {}),
2857
+ });
2858
+ return client;
2859
+ },
2860
+ use: operation,
2861
+ });
2862
+ }
2863
+
2864
+ async function withActiveBrowserController<T>(
2865
+ context: Context,
2866
+ grant: AccessGrant,
2867
+ workspaceId: string,
2868
+ browserSessionId: string,
2869
+ authorizationOperation: SessionAuthorizationOperation,
2870
+ channelOperation: ChannelAOperation,
2871
+ callback: (input: {
2872
+ client: BrowserControlClient;
2873
+ sessionClient: ReturnType<BrowserControlClient["sessionClient"]>;
2874
+ record: BrowserSessionControlRecord;
2875
+ binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
2876
+ placement: BrowserPlacement;
2877
+ }) => Promise<T>,
2878
+ recoverMissing = true,
2879
+ ): Promise<T> {
2880
+ try {
2881
+ const record = await getBrowserSessionControlRecord(deps.db, {
2882
+ accountId: grant.accountId,
2883
+ workspaceId,
2884
+ browserSessionId,
2885
+ });
2886
+ await authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation);
2887
+ if (record.session.lifecycle !== "active" || !record.session.controller) {
2888
+ throw new BrowserSessionStateError("BrowserSession is not active");
2889
+ }
2890
+ const binding = record.session.controller;
2891
+ const admitted = await touchBrowserSessionController(deps.db, {
2892
+ accountId: grant.accountId,
2893
+ workspaceId,
2894
+ browserSessionId,
2895
+ controllerGeneration: binding.controllerGeneration,
2896
+ });
2897
+ if (!admitted) {
2898
+ throw new BrowserSessionStateError("BrowserSession controller authority changed");
2899
+ }
2900
+ const run = async (placement: BrowserPlacement): Promise<T> => {
2901
+ // An active binding already proves browserd was provisioned. Reusing
2902
+ // it avoids restarting/checking the sidecar on every live input.
2903
+ const client = connectController(deps, grant, record, placement);
2904
+ const tokens = deriveBrowserSessionControllerTokens({
2905
+ rootSecret: browserAuthorityRoot(deps),
2906
+ accountId: grant.accountId,
2907
+ workspaceId,
2908
+ placement: record.session.placement,
2909
+ placementInstanceId: placement.placementInstanceId,
2910
+ browserSessionId,
2911
+ controllerGeneration: binding.controllerGeneration,
2912
+ tokenGeneration: record.tokenGeneration,
2913
+ });
2914
+ const controller = {
2915
+ client,
2916
+ sessionClient: client.sessionClient({
2917
+ reference: {
2918
+ browserSessionId,
2919
+ controllerGeneration: binding.controllerGeneration,
2920
+ },
2921
+ ...tokens,
2922
+ }),
2923
+ record,
2924
+ binding,
2925
+ placement,
2926
+ };
2927
+ let result: T;
2928
+ try {
2929
+ result = await callback(controller);
2930
+ } catch (error) {
2931
+ if (!recoverMissing || !isMissingBrowserControllerSession(error)) throw error;
2932
+ await recoverActiveBrowserController(
2933
+ deps,
2934
+ grant,
2935
+ record,
2936
+ binding,
2937
+ placement,
2938
+ client,
2939
+ tokens,
2940
+ );
2941
+ result = await callback(controller);
2942
+ }
2943
+ return result;
2944
+ };
2945
+ const cached = await cachedBrowserPlacement(record);
2946
+ if (cached) {
2947
+ try {
2948
+ return await run(cached);
2949
+ } catch (error) {
2950
+ const safelyReplayable =
2951
+ channelOperation === "browser.read" || channelOperation === "browser.action";
2952
+ if (
2953
+ !safelyReplayable ||
2954
+ (!(error instanceof BrowserControlTransportError) &&
2955
+ !(error instanceof BrowserControlRequestError && error.retryable))
2956
+ ) {
2957
+ throw error;
2958
+ }
2959
+ await recordLeaseControllerDataPlaneUrl(deps.db, {
2960
+ accountId: grant.accountId,
2961
+ workspaceId,
2962
+ sandboxGroupId: cached.controllerHostSandboxGroupId!,
2963
+ expectedEpoch: cached.lease!.leaseEpoch,
2964
+ expectedInstanceId: cached.placementInstanceId,
2965
+ controllerDataPlaneUrl: null,
2966
+ }).catch(() => null);
2967
+ }
2968
+ }
2969
+
2970
+ const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
2971
+ return await withBrowserPlacement(
2972
+ sourceSession,
2973
+ grant,
2974
+ record.session.placement,
2975
+ binding.placementInstanceId,
2976
+ channelOperation,
2977
+ context.req.raw.signal,
2978
+ async (placement) =>
2979
+ await run(
2980
+ await cacheBrowserControllerPlacement(grant, workspaceId, placement).catch(
2981
+ () => placement,
2982
+ ),
2983
+ ),
2984
+ );
2985
+ } catch (error) {
2986
+ throw browserRouteError(error);
2987
+ }
2988
+ }
2989
+
2990
+ async function recoverActiveBrowserController(
2991
+ routeDeps: ApiRouteDeps,
2992
+ grant: AccessGrant,
2993
+ record: BrowserSessionControlRecord,
2994
+ binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>,
2995
+ placement: BrowserPlacement,
2996
+ client: BrowserControlClient,
2997
+ tokens: ReturnType<typeof deriveBrowserSessionControllerTokens>,
2998
+ ): Promise<void> {
2999
+ const linkedComputer = await requireLinkedComputerBinding(
3000
+ routeDeps,
3001
+ grant,
3002
+ record.session,
3003
+ placement,
3004
+ );
3005
+ const networkRoute = await resolveActiveBrowserNetworkRouteLaunch({
3006
+ deps: routeDeps,
3007
+ grant,
3008
+ record,
3009
+ placement: placement.placement,
3010
+ });
3011
+ try {
3012
+ await client.createSession({
3013
+ browserSessionId: record.session.id,
3014
+ controllerGeneration: binding.controllerGeneration,
3015
+ tokenGeneration: record.tokenGeneration,
3016
+ ...tokens,
3017
+ headed: !record.session.headless,
3018
+ transport: browserRuntimeTransport(record.session, placement.transport),
3019
+ ...(linkedComputer ? { linkedComputer } : {}),
3020
+ ...(networkRoute ? { networkRoute } : {}),
3021
+ });
3022
+ } catch (error) {
3023
+ console.error("browser controller recovery failed", {
3024
+ browserSessionId: record.session.id,
3025
+ controllerGeneration: binding.controllerGeneration,
3026
+ failure: interactionFailure(error),
3027
+ });
3028
+ throw error;
3029
+ }
3030
+ }
3031
+
3032
+ async function finishSuspendedBrowserControllerCleanup(
3033
+ context: Context,
3034
+ grant: AccessGrant,
3035
+ workspaceId: string,
3036
+ browserSessionId: string,
3037
+ ): Promise<void> {
3038
+ const record = await getBrowserSessionControlRecord(deps.db, {
3039
+ accountId: grant.accountId,
3040
+ workspaceId,
3041
+ browserSessionId,
3042
+ });
3043
+ if (record.session.lifecycle !== "suspended") {
3044
+ throw new BrowserSessionStateError("BrowserSession is not suspended");
3045
+ }
3046
+ const binding = record.session.controller;
3047
+ if (binding) {
3048
+ const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
3049
+ await withBrowserPlacement(
3050
+ sourceSession,
3051
+ grant,
3052
+ record.session.placement,
3053
+ binding.placementInstanceId,
3054
+ "browser.suspend",
3055
+ context.req.raw.signal,
3056
+ async (placement) => {
3057
+ const client = await provisionController(
3058
+ deps,
3059
+ grant,
3060
+ record,
3061
+ placement,
3062
+ requestOrigin(context, deps.settings.corsAllowOriginRegex),
3063
+ );
3064
+ await endCapturedBrowserController(client, browserSessionId, binding);
3065
+ await clearSuspendedBrowserSessionController(deps.db, {
3066
+ accountId: grant.accountId,
3067
+ workspaceId,
3068
+ browserSessionId,
3069
+ expectedControllerGeneration: binding.controllerGeneration,
3070
+ });
3071
+ },
3072
+ );
3073
+ }
3074
+ await releaseInteractionHolder(
3075
+ deps,
3076
+ grant,
3077
+ workspaceId,
3078
+ browserSessionId,
3079
+ record.controllerHostSandboxGroupId,
3080
+ );
3081
+ }
3082
+ }
3083
+
3084
+ function browserCreateInput(
3085
+ grant: AccessGrant,
3086
+ workspaceId: string,
3087
+ request: CreateBrowserSessionRequestValue,
3088
+ placement: InteractionPlacement,
3089
+ ) {
3090
+ const attached = placement.kind === "attached_device";
3091
+ const external = placement.kind === "external_provider";
3092
+ const engine = attached ? ("chrome" as const) : external ? ("external" as const) : request.engine;
3093
+ if (engine === "lightpanda" && placement.kind !== "sandbox_group") {
3094
+ throw new BrowserControlUnsupportedError(
3095
+ "Lightpanda is currently available only in managed agent sandboxes",
3096
+ );
3097
+ }
3098
+ return {
3099
+ accountId: grant.accountId,
3100
+ workspaceId,
3101
+ operationId: request.operationId,
3102
+ associatedSessionId: request.sessionId,
3103
+ actorSubjectId: grant.subjectId,
3104
+ name: request.name ?? "Browser",
3105
+ initialUrl: request.initialUrl ?? null,
3106
+ placement,
3107
+ driverId:
3108
+ engine === "lightpanda"
3109
+ ? LIGHTPANDA_DRIVER_ID
3110
+ : external
3111
+ ? EXTERNAL_BROWSER_DRIVER_ID
3112
+ : BROWSER_DRIVER_ID,
3113
+ engine,
3114
+ headless: attached ? false : request.headless,
3115
+ identityId: request.identityId ?? null,
3116
+ baseRevisionId: request.baseRevisionId ?? null,
3117
+ networkRouteId: request.networkRouteId ?? null,
3118
+ linkedComputerSessionId: request.linkedComputerSessionId ?? null,
3119
+ resolveDefaultRevision:
3120
+ !attached &&
3121
+ !external &&
3122
+ request.identityId !== undefined &&
3123
+ request.baseRevisionId === undefined,
3124
+ ...(attached
3125
+ ? { capabilities: ATTACHED_BROWSER_SESSION_CAPABILITIES }
3126
+ : external
3127
+ ? { capabilities: EXTERNAL_BROWSER_SESSION_CAPABILITIES }
3128
+ : engine === "lightpanda"
3129
+ ? { capabilities: LIGHTPANDA_BROWSER_SESSION_CAPABILITIES }
3130
+ : {}),
3131
+ };
3132
+ }
3133
+
3134
+ function browserRuntimeTransport(
3135
+ session: BrowserSessionValue,
3136
+ transport: PlacementBrowserTransport,
3137
+ ): PlacementBrowserTransport {
3138
+ if (transport.kind !== "managed") return transport;
3139
+ return {
3140
+ kind: "managed",
3141
+ engine: session.engine === "lightpanda" ? "lightpanda" : "chromium",
3142
+ };
3143
+ }
3144
+
3145
+ function externalBrowserTransport(
3146
+ deps: ApiRouteDeps,
3147
+ placement: Extract<InteractionPlacement, { kind: "external_provider" }>,
3148
+ ): PlacementBrowserTransport {
3149
+ if (placement.placementId !== "default") {
3150
+ throw new BrowserControlUnsupportedError(
3151
+ "only the configured default external browser placement is available",
3152
+ );
3153
+ }
3154
+ if (placement.providerId === "browserbase") {
3155
+ if (!deps.settings.browserbaseApiKey) {
3156
+ throw new HTTPException(503, {
3157
+ message: "Browserbase browser placement is not configured",
3158
+ });
3159
+ }
3160
+ return {
3161
+ kind: "external_provider",
3162
+ providerId: "browserbase",
3163
+ placementId: placement.placementId,
3164
+ authority: { apiKey: deps.settings.browserbaseApiKey },
3165
+ };
3166
+ }
3167
+ if (placement.providerId === "kernel") {
3168
+ if (!deps.settings.kernelApiKey) {
3169
+ throw new HTTPException(503, {
3170
+ message: "Kernel browser placement is not configured",
3171
+ });
3172
+ }
3173
+ return {
3174
+ kind: "external_provider",
3175
+ providerId: "kernel",
3176
+ placementId: placement.placementId,
3177
+ authority: {
3178
+ apiKey: deps.settings.kernelApiKey,
3179
+ ...(deps.settings.kernelEndpoint ? { endpoint: deps.settings.kernelEndpoint } : {}),
3180
+ },
3181
+ timeoutSeconds: deps.settings.kernelBrowserTimeoutSeconds,
3182
+ stealth: deps.settings.kernelBrowserStealth,
3183
+ };
3184
+ }
3185
+ throw new BrowserControlUnsupportedError(
3186
+ `external browser provider ${placement.providerId} is not supported`,
3187
+ );
3188
+ }
3189
+
3190
+ function assertCreateReplay(
3191
+ request: CreateBrowserSessionRequestValue,
3192
+ session: BrowserSessionValue,
3193
+ ): void {
3194
+ if (request.placement && !sameInteractionPlacement(request.placement, session.placement)) {
3195
+ throw new BrowserSessionOperationConflictError(
3196
+ "BrowserSession create operation is bound to another placement",
3197
+ );
3198
+ }
3199
+ const expectedEngine =
3200
+ session.placement.kind === "attached_device"
3201
+ ? "chrome"
3202
+ : session.placement.kind === "external_provider"
3203
+ ? "external"
3204
+ : request.engine;
3205
+ if (session.engine !== expectedEngine) {
3206
+ throw new BrowserSessionOperationConflictError(
3207
+ "BrowserSession create operation is bound to another browser engine",
3208
+ );
3209
+ }
3210
+ if ((request.identityId ?? null) !== session.identityId) {
3211
+ throw new BrowserSessionOperationConflictError(
3212
+ "BrowserSession create operation is bound to another identity",
3213
+ );
3214
+ }
3215
+ if ((request.networkRouteId ?? null) !== session.networkRouteId) {
3216
+ throw new BrowserSessionOperationConflictError(
3217
+ "BrowserSession create operation is bound to another network route",
3218
+ );
3219
+ }
3220
+ if (request.baseRevisionId !== undefined && request.baseRevisionId !== session.baseRevisionId) {
3221
+ throw new BrowserSessionOperationConflictError(
3222
+ "BrowserSession create operation is bound to another revision",
3223
+ );
3224
+ }
3225
+ if ((request.linkedComputerSessionId ?? null) !== session.linkedComputerSessionId) {
3226
+ throw new BrowserSessionOperationConflictError(
3227
+ "BrowserSession create operation is bound to another ComputerSession",
3228
+ );
3229
+ }
3230
+ }
3231
+
3232
+ async function requireLinkedComputerBinding(
3233
+ deps: ApiRouteDeps,
3234
+ grant: AccessGrant,
3235
+ browser: BrowserSessionValue,
3236
+ placement: BrowserPlacement,
3237
+ ): Promise<{ computerSessionId: string; controllerGeneration: string } | null> {
3238
+ if (!browser.linkedComputerSessionId) return null;
3239
+ const record = await getComputerSessionControlRecord(deps.db, {
3240
+ accountId: grant.accountId,
3241
+ workspaceId: browser.workspaceId,
3242
+ computerSessionId: browser.linkedComputerSessionId,
3243
+ });
3244
+ if (
3245
+ record.session.lifecycle !== "active" ||
3246
+ !record.session.controller ||
3247
+ !sameInteractionPlacement(record.session.placement, browser.placement) ||
3248
+ record.session.controller.placementInstanceId !== placement.placementInstanceId
3249
+ ) {
3250
+ throw new BrowserSessionStateError(
3251
+ "Linked ComputerSession is not active on the browser placement",
3252
+ );
3253
+ }
3254
+ return {
3255
+ computerSessionId: record.session.id,
3256
+ controllerGeneration: record.session.controller.controllerGeneration,
3257
+ };
3258
+ }
3259
+
3260
+ async function requireBrowserRevisionRestoreAuthority(
3261
+ deps: ApiRouteDeps,
3262
+ grant: AccessGrant,
3263
+ session: BrowserSessionValue,
3264
+ ): Promise<BrowserRevisionArtifactAuthority> {
3265
+ if (!session.identityId || !session.baseRevisionId) {
3266
+ throw new BrowserSessionStateError("BrowserSession has no revision recovery authority");
3267
+ }
3268
+ return await getBrowserRevisionArtifactAuthority(deps.db, {
3269
+ accountId: grant.accountId,
3270
+ workspaceId: session.workspaceId,
3271
+ identityId: session.identityId,
3272
+ revisionId: session.baseRevisionId,
3273
+ });
3274
+ }
3275
+
3276
+ async function prepareBrowserStateRestore(
3277
+ deps: ApiRouteDeps,
3278
+ grant: AccessGrant,
3279
+ session: BrowserSessionValue,
3280
+ placement: InteractionPlacement,
3281
+ authority: BrowserRevisionArtifactAuthority | null,
3282
+ ): Promise<RestorePlacementBrowserStateInput | null> {
3283
+ if (!session.baseRevisionId) {
3284
+ if (authority) {
3285
+ throw new BrowserIdentityStateError("BrowserSession has unexpected revision authority");
3286
+ }
3287
+ return null;
3288
+ }
3289
+ if (
3290
+ !session.identityId ||
3291
+ !authority ||
3292
+ authority.revision.id !== session.baseRevisionId ||
3293
+ authority.revision.identityId !== session.identityId
3294
+ ) {
3295
+ throw new BrowserIdentityStateError("BrowserSession revision authority is inconsistent");
3296
+ }
3297
+ if (
3298
+ authority.artifacts.length !== 1 ||
3299
+ authority.revision.components.length !== 1 ||
3300
+ authority.revision.components[0]?.kind !== "chromium_profile"
3301
+ ) {
3302
+ throw new BrowserIdentityStateError(
3303
+ "BrowserRevision does not have one restorable Chromium profile",
3304
+ );
3305
+ }
3306
+ const artifact = authority.artifacts[0]!;
3307
+ if (artifact.manifestDigest !== authority.revision.manifestDigest) {
3308
+ throw new BrowserIdentityStateError("BrowserRevision manifest authority is inconsistent");
3309
+ }
3310
+ return await prepareBrowserArtifactRestore(
3311
+ deps,
3312
+ grant,
3313
+ session,
3314
+ placement,
3315
+ artifact,
3316
+ authority.revision.manifestDigest,
3317
+ );
3318
+ }
3319
+
3320
+ async function prepareBrowserPrivateCheckpointRestore(
3321
+ deps: ApiRouteDeps,
3322
+ grant: AccessGrant,
3323
+ session: BrowserSessionValue,
3324
+ authority: BrowserPrivateCheckpointAuthority,
3325
+ ): Promise<RestorePlacementBrowserStateInput> {
3326
+ if (authority.sourceBrowserSessionId !== session.id) {
3327
+ throw new BrowserSessionStateError("BrowserSession private checkpoint belongs elsewhere");
3328
+ }
3329
+ return await prepareBrowserArtifactRestore(
3330
+ deps,
3331
+ grant,
3332
+ session,
3333
+ session.placement,
3334
+ authority,
3335
+ authority.manifestDigest,
3336
+ );
3337
+ }
3338
+
3339
+ async function prepareBrowserArtifactRestore(
3340
+ deps: ApiRouteDeps,
3341
+ grant: AccessGrant,
3342
+ session: BrowserSessionValue,
3343
+ placement: InteractionPlacement,
3344
+ artifact: {
3345
+ objectKey: string;
3346
+ encryptedDataKey: string;
3347
+ format: string;
3348
+ artifactDigest: string;
3349
+ contentDigest: string;
3350
+ sizeBytes: number;
3351
+ materialization: BrowserRevisionMaterializationValue;
3352
+ },
3353
+ manifestDigest: string,
3354
+ ): Promise<RestorePlacementBrowserStateInput> {
3355
+ if (
3356
+ artifact.format !== BROWSER_PROFILE_ARTIFACT_FORMAT ||
3357
+ artifact.materialization.engine !== "chromium" ||
3358
+ artifact.materialization.driverId !== BROWSER_DRIVER_ID ||
3359
+ artifact.materialization.driverSchemaVersion !== 1
3360
+ ) {
3361
+ throw new BrowserIdentityStateError("Browser state requires another browser driver");
3362
+ }
3363
+ assertMaterializationPlacement(artifact.materialization, placement);
3364
+ const objectStorage = deps.objectStorage;
3365
+ if (!objectStorage) {
3366
+ throw new HTTPException(503, {
3367
+ message: "browser state storage is not configured",
3368
+ });
3369
+ }
3370
+ if (artifact.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
3371
+ throw new BrowserIdentityStateError("Browser state artifact exceeds the supported byte limit");
3372
+ }
3373
+ const rootKey = requireBrowserStateRoot(deps);
3374
+ let dataKey: Buffer;
3375
+ try {
3376
+ dataKey = unwrapBrowserStateDataKey(rootKey, artifact.encryptedDataKey, {
3377
+ accountId: grant.accountId,
3378
+ workspaceId: session.workspaceId,
3379
+ objectKey: artifact.objectKey,
3380
+ artifactDigest: artifact.artifactDigest,
3381
+ contentDigest: artifact.contentDigest,
3382
+ });
3383
+ } catch (error) {
3384
+ throw new BrowserIdentityStateError("Browser state encryption authority is unavailable", {
3385
+ cause: error,
3386
+ });
3387
+ } finally {
3388
+ rootKey.fill(0);
3389
+ }
3390
+ const aad = browserStateArtifactAad({
3391
+ accountId: grant.accountId,
3392
+ workspaceId: session.workspaceId,
3393
+ objectKey: artifact.objectKey,
3394
+ });
3395
+ try {
3396
+ const signed = await objectStorage.createGetUrl({
3397
+ key: artifact.objectKey,
3398
+ audience: signedUrlAudienceForPlacement(placement),
3399
+ });
3400
+ return {
3401
+ objectKey: artifact.objectKey,
3402
+ format: BROWSER_PROFILE_ARTIFACT_FORMAT,
3403
+ artifactDigest: artifact.artifactDigest,
3404
+ contentDigest: artifact.contentDigest,
3405
+ manifestDigest,
3406
+ sizeBytes: artifact.sizeBytes,
3407
+ dataKey,
3408
+ aad,
3409
+ materialization: artifact.materialization,
3410
+ download: {
3411
+ url: signed.url,
3412
+ expiresAt: signed.expiresAt.toISOString(),
3413
+ },
3414
+ };
3415
+ } catch (error) {
3416
+ dataKey.fill(0);
3417
+ aad.fill(0);
3418
+ throw error;
3419
+ }
3420
+ }
3421
+
3422
+ function assertMaterializationPlacement(
3423
+ materialization: BrowserRevisionMaterializationValue,
3424
+ placement: InteractionPlacement,
3425
+ ): void {
3426
+ if (
3427
+ materialization.portability === "placement_bound" &&
3428
+ (!materialization.placement || !sameInteractionPlacement(materialization.placement, placement))
3429
+ ) {
3430
+ throw new BrowserIdentityStateError("BrowserRevision is bound to another placement");
3431
+ }
3432
+ if (
3433
+ materialization.portability === "provider_bound" &&
3434
+ (placement.kind !== "external_provider" || materialization.providerId !== placement.providerId)
3435
+ ) {
3436
+ throw new BrowserIdentityStateError("BrowserRevision is bound to another browser provider");
3437
+ }
3438
+ }
3439
+
3440
+ function sameInteractionPlacement(
3441
+ left: InteractionPlacement,
3442
+ right: InteractionPlacement,
3443
+ ): boolean {
3444
+ if (left.kind !== right.kind) return false;
3445
+ switch (left.kind) {
3446
+ case "sandbox_group":
3447
+ return right.kind === "sandbox_group" && left.sandboxGroupId === right.sandboxGroupId;
3448
+ case "connected_machine":
3449
+ return right.kind === "connected_machine" && left.sandboxId === right.sandboxId;
3450
+ case "attached_device":
3451
+ return right.kind === "attached_device" && left.deviceId === right.deviceId;
3452
+ case "external_provider":
3453
+ return (
3454
+ right.kind === "external_provider" &&
3455
+ left.providerId === right.providerId &&
3456
+ left.placementId === right.placementId
3457
+ );
3458
+ }
3459
+ }
3460
+
3461
+ function signedUrlAudienceForPlacement(placement: InteractionPlacement): "public" | "sandbox" {
3462
+ return placement.kind === "sandbox_group" ? "sandbox" : "public";
3463
+ }
3464
+
3465
+ async function ensureInteractionHolder(
3466
+ deps: ApiRouteDeps,
3467
+ grant: AccessGrant,
3468
+ sourceSession: Session,
3469
+ browserSessionId: string,
3470
+ placement: BrowserPlacement,
3471
+ waitSignal: AbortSignal,
3472
+ ): Promise<boolean> {
3473
+ if (!placement.controllerHostSandboxGroupId) return false;
3474
+ if (!placement.lease?.instanceId) {
3475
+ throw new BrowserSessionStateError("BrowserSession lease placement is unavailable");
3476
+ }
3477
+ const acquired = await acquireLease(deps.db, {
3478
+ accountId: grant.accountId,
3479
+ workspaceId: sourceSession.workspaceId,
3480
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
3481
+ kind: "interaction",
3482
+ holderId: interactionHolderId(browserSessionId),
3483
+ subjectId: sourceSession.id,
3484
+ backend: placement.lease.backend,
3485
+ os: placement.lease.os,
3486
+ image: placement.lease.image,
3487
+ rigVersionId: placement.lease.rigVersionId,
3488
+ leaseTtlMs: deps.settings.sandboxLeaseTtlMs,
3489
+ expectedEpoch: placement.lease.leaseEpoch,
3490
+ waitSignal,
3491
+ });
3492
+ if (acquired.role === "blocked" || acquired.role === "fenced") {
3493
+ throw new BrowserSessionStateError("BrowserSession placement is transitioning; retry");
3494
+ }
3495
+ if (
3496
+ acquired.role === "spawner" ||
3497
+ acquired.lease.leaseEpoch !== placement.lease.leaseEpoch ||
3498
+ acquired.lease.instanceId !== placement.placementInstanceId
3499
+ ) {
3500
+ await releaseInteractionHolder(
3501
+ deps,
3502
+ grant,
3503
+ sourceSession.workspaceId,
3504
+ browserSessionId,
3505
+ placement.controllerHostSandboxGroupId,
3506
+ ).catch(() => undefined);
3507
+ throw new BrowserSessionStateError("BrowserSession placement fence changed; retry");
3508
+ }
3509
+ return true;
3510
+ }
3511
+
3512
+ async function ensureDispatchedGeneration(
3513
+ deps: ApiRouteDeps,
3514
+ grant: AccessGrant,
3515
+ workspaceId: string,
3516
+ browserSessionId: string,
3517
+ operationId: string,
3518
+ placementInstanceId: string,
3519
+ ): Promise<BrowserSessionControlRecord> {
3520
+ let record = await getBrowserSessionControlRecord(deps.db, {
3521
+ accountId: grant.accountId,
3522
+ workspaceId,
3523
+ browserSessionId,
3524
+ operationId,
3525
+ });
3526
+ if (record.operation?.state === "prepared") {
3527
+ const controllerGeneration = randomUUID();
3528
+ try {
3529
+ await dispatchBrowserSessionOperation(deps.db, {
3530
+ accountId: grant.accountId,
3531
+ workspaceId,
3532
+ operationId,
3533
+ browserSessionId,
3534
+ controllerGeneration,
3535
+ controller: {
3536
+ controllerId: "opengeni-browserd",
3537
+ controllerGeneration,
3538
+ placementInstanceId,
3539
+ },
3540
+ });
3541
+ } catch (error) {
3542
+ if (!(error instanceof BrowserSessionOperationConflictError)) throw error;
3543
+ }
3544
+ record = await getBrowserSessionControlRecord(deps.db, {
3545
+ accountId: grant.accountId,
3546
+ workspaceId,
3547
+ browserSessionId,
3548
+ operationId,
3549
+ });
3550
+ }
3551
+ if (record.operation?.state !== "dispatched") {
3552
+ throw new BrowserSessionStateError("BrowserSession operation is not dispatchable");
3553
+ }
3554
+ if (
3555
+ !record.session.controller ||
3556
+ record.session.controller.controllerGeneration !== record.operation.controllerGeneration ||
3557
+ record.session.controller.placementInstanceId !== placementInstanceId
3558
+ ) {
3559
+ throw new BrowserSessionOperationConflictError(
3560
+ "BrowserSession dispatch controller binding is inconsistent",
3561
+ );
3562
+ }
3563
+ return record;
3564
+ }
3565
+
3566
+ function requireOperationGeneration(record: BrowserSessionControlRecord): string {
3567
+ const generation = record.operation?.controllerGeneration;
3568
+ if (!generation) throw new BrowserSessionStateError("BrowserSession controller fence is absent");
3569
+ return generation;
3570
+ }
3571
+
3572
+ async function provisionController(
3573
+ deps: ApiRouteDeps,
3574
+ grant: AccessGrant,
3575
+ record: BrowserSessionControlRecord,
3576
+ placement: BrowserPlacement,
3577
+ origin: string | null,
3578
+ ): Promise<BrowserControlClient> {
3579
+ const rootSecret = browserAuthorityRoot(deps);
3580
+ const adminToken = deriveBrowserControllerAdminToken({
3581
+ rootSecret,
3582
+ accountId: grant.accountId,
3583
+ workspaceId: record.session.workspaceId,
3584
+ placement: record.session.placement,
3585
+ placementInstanceId: placement.placementInstanceId,
3586
+ });
3587
+ return (
3588
+ await provisionBrowserControlClient(placement.session, {
3589
+ adminToken,
3590
+ nativeAuthority: nativeBrowserControllerAuthority(record.session.workspaceId, placement),
3591
+ ...(origin ? { allowedOrigins: [origin] } : {}),
3592
+ })
3593
+ ).client;
3594
+ }
3595
+
3596
+ function connectController(
3597
+ deps: ApiRouteDeps,
3598
+ grant: AccessGrant,
3599
+ record: BrowserSessionControlRecord,
3600
+ placement: BrowserPlacement,
3601
+ ): BrowserControlClient {
3602
+ return new BrowserControlClient(placement.session, {
3603
+ adminToken: deriveBrowserControllerAdminToken({
3604
+ rootSecret: browserAuthorityRoot(deps),
3605
+ accountId: grant.accountId,
3606
+ workspaceId: record.session.workspaceId,
3607
+ placement: record.session.placement,
3608
+ placementInstanceId: placement.placementInstanceId,
3609
+ }),
3610
+ nativeAuthority: nativeBrowserControllerAuthority(record.session.workspaceId, placement),
3611
+ });
3612
+ }
3613
+
3614
+ function nativeBrowserControllerAuthority(
3615
+ workspaceId: string,
3616
+ placement: BrowserPlacement,
3617
+ ): { scopeId: string; scopeGeneration: string } {
3618
+ const placementId = (() => {
3619
+ switch (placement.placement.kind) {
3620
+ case "attached_device":
3621
+ return placement.placement.deviceId;
3622
+ case "connected_machine":
3623
+ return placement.placement.sandboxId;
3624
+ case "sandbox_group":
3625
+ return placement.placement.sandboxGroupId;
3626
+ case "external_provider":
3627
+ return `${placement.placement.providerId}:${placement.placement.placementId}`;
3628
+ }
3629
+ })();
3630
+ return {
3631
+ scopeId: `${workspaceId}:${placement.placement.kind}:${placementId}`,
3632
+ scopeGeneration: placement.placementInstanceId,
3633
+ };
3634
+ }
3635
+
3636
+ async function releaseInteractionHolder(
3637
+ deps: ApiRouteDeps,
3638
+ grant: AccessGrant,
3639
+ workspaceId: string,
3640
+ browserSessionId: string,
3641
+ controllerHostSandboxGroupId: string | null,
3642
+ ): Promise<void> {
3643
+ if (!controllerHostSandboxGroupId) return;
3644
+ await releaseLeaseHolder(deps.db, {
3645
+ accountId: grant.accountId,
3646
+ workspaceId,
3647
+ sandboxGroupId: controllerHostSandboxGroupId,
3648
+ kind: "interaction",
3649
+ holderId: interactionHolderId(browserSessionId),
3650
+ idleGraceMs: deps.settings.sandboxIdleGraceMs,
3651
+ });
3652
+ }
3653
+
3654
+ function interactionHolderId(browserSessionId: string): string {
3655
+ return `browser-session:${browserSessionId}`;
3656
+ }
3657
+
3658
+ function browserAuthorityRoot(deps: ApiRouteDeps): string {
3659
+ const root = resolveFirstPartyDelegationSecret(deps.settings);
3660
+ if (!root) {
3661
+ throw new HTTPException(503, {
3662
+ message: "browser controller authority is not configured",
3663
+ });
3664
+ }
3665
+ return root;
3666
+ }
3667
+
3668
+ function requireBrowserStateRoot(deps: ApiRouteDeps): Buffer {
3669
+ const configured = environmentsEncryptionKeyBytes(deps.settings);
3670
+ if (!configured) {
3671
+ throw new HTTPException(503, {
3672
+ message: "browser state encryption is not configured",
3673
+ });
3674
+ }
3675
+ const root = Buffer.from(configured);
3676
+ configured.fill(0);
3677
+ return root;
3678
+ }
3679
+
3680
+ function browserRevisionMaterialization(
3681
+ manifest: PlacementBrowserStateCaptureReceipt["manifest"],
3682
+ placement: InteractionPlacement,
3683
+ ): BrowserRevisionMaterializationValue {
3684
+ const portable = manifest.profileCrypto !== "platform_bound";
3685
+ return {
3686
+ portability: portable ? "portable" : "placement_bound",
3687
+ reason: portable
3688
+ ? null
3689
+ : "Profile encryption depends on the source operating-system credential store.",
3690
+ platform: manifest.platform,
3691
+ architecture: manifest.architecture,
3692
+ engine: manifest.engine,
3693
+ engineVersion: manifest.engineVersion,
3694
+ driverId: manifest.driverId,
3695
+ driverSchemaVersion: manifest.driverSchemaVersion,
3696
+ profileCrypto: manifest.profileCrypto,
3697
+ providerId: null,
3698
+ placement: portable ? null : placement,
3699
+ };
3700
+ }
3701
+
3702
+ async function endCapturedBrowserController(
3703
+ client: BrowserControlClient,
3704
+ browserSessionId: string,
3705
+ binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>,
3706
+ ): Promise<void> {
3707
+ try {
3708
+ await client.endSession(
3709
+ {
3710
+ browserSessionId,
3711
+ controllerGeneration: binding.controllerGeneration,
3712
+ },
3713
+ { removeState: true },
3714
+ );
3715
+ } catch (error) {
3716
+ if (!(error instanceof BrowserControlRequestError && error.status === 404)) throw error;
3717
+ }
3718
+ }
3719
+
3720
+ function isDefiniteBrowserControllerFailure(error: unknown): boolean {
3721
+ return (
3722
+ error instanceof BrowserControlRequestError &&
3723
+ !error.error.retryable &&
3724
+ error.error.code !== "outcome_unknown"
3725
+ );
3726
+ }
3727
+
3728
+ function isMissingBrowserControllerSession(error: unknown): boolean {
3729
+ if (!(error instanceof BrowserControlRequestError)) return false;
3730
+ if (error.status === 401 && error.error.code === "permission_denied") return true;
3731
+ return (
3732
+ error.status === 404 &&
3733
+ error.error.code === "resource_not_found" &&
3734
+ ["browser session not found", "browser session is not active"].includes(error.error.message)
3735
+ );
3736
+ }
3737
+
3738
+ async function settleBrowserSessionSuspensionCaptureFailure(
3739
+ deps: ApiRouteDeps,
3740
+ input: {
3741
+ accountId: string;
3742
+ workspaceId: string;
3743
+ operationId: string;
3744
+ browserSessionId: string;
3745
+ controllerGeneration: string;
3746
+ },
3747
+ error: unknown,
3748
+ ) {
3749
+ if (!(error instanceof BrowserControlRequestError)) return null;
3750
+ const outcomeUnknown = error.error.code === "outcome_unknown";
3751
+ if (!outcomeUnknown && error.error.retryable) return null;
3752
+ return await failBrowserSessionSuspension(deps.db, {
3753
+ ...input,
3754
+ ...(outcomeUnknown ? { state: "outcome_unknown" as const } : {}),
3755
+ error: error.error,
3756
+ });
3757
+ }
3758
+
3759
+ async function settleBrowserRevisionCaptureFailure(
3760
+ deps: ApiRouteDeps,
3761
+ input: {
3762
+ accountId: string;
3763
+ workspaceId: string;
3764
+ operationId: string;
3765
+ browserSessionId: string;
3766
+ controllerGeneration: string;
3767
+ },
3768
+ error: unknown,
3769
+ ): Promise<void> {
3770
+ if (!(error instanceof BrowserControlRequestError)) return;
3771
+ const outcomeUnknown = error.error.code === "outcome_unknown";
3772
+ if (!outcomeUnknown && error.error.retryable) return;
3773
+ await failBrowserRevisionPublication(deps.db, {
3774
+ ...input,
3775
+ state: outcomeUnknown ? "outcome_unknown" : "failed",
3776
+ error: error.error,
3777
+ });
3778
+ }
3779
+
3780
+ async function requireSourceSession(
3781
+ deps: ApiRouteDeps,
3782
+ workspaceId: string,
3783
+ sessionId: string,
3784
+ ): Promise<Session> {
3785
+ const session = await getSession(deps.db, workspaceId, sessionId);
3786
+ if (!session) throw new BrowserSessionNotFoundError("Associated session not found");
3787
+ return session;
3788
+ }
3789
+
3790
+ async function authorizeSourceSession(
3791
+ deps: ApiRouteDeps,
3792
+ grant: AccessGrant,
3793
+ sessionId: string,
3794
+ operation: SessionAuthorizationOperation,
3795
+ ): Promise<void> {
3796
+ try {
3797
+ await requireSessionAuthorization(deps, grant, {
3798
+ sessionId,
3799
+ operation,
3800
+ surface: "http",
3801
+ });
3802
+ } catch (error) {
3803
+ if (error instanceof SessionAuthorizationDeniedError) {
3804
+ throw new HTTPException(404, {
3805
+ message: "session not found",
3806
+ cause: error,
3807
+ });
3808
+ }
3809
+ if (error instanceof SessionAuthorizationUnavailableError) {
3810
+ throw new HTTPException(503, {
3811
+ message: "session authorization is unavailable",
3812
+ cause: error,
3813
+ });
3814
+ }
3815
+ throw error;
3816
+ }
3817
+ }
3818
+
3819
+ async function parseJsonBody<T>(
3820
+ context: Context,
3821
+ schema: {
3822
+ safeParse(value: unknown): { success: true; data: T } | { success: false };
3823
+ },
3824
+ ): Promise<T> {
3825
+ const value = await context.req.json().catch(() => undefined);
3826
+ const parsed = schema.safeParse(value);
3827
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid request body" });
3828
+ return parsed.data;
3829
+ }
3830
+
3831
+ async function parseEmptyJsonBody(context: Context): Promise<void> {
3832
+ const text = await context.req.text();
3833
+ if (!text.trim()) return;
3834
+ try {
3835
+ const value = JSON.parse(text) as unknown;
3836
+ if (
3837
+ typeof value !== "object" ||
3838
+ value === null ||
3839
+ Array.isArray(value) ||
3840
+ Object.keys(value).length !== 0
3841
+ ) {
3842
+ throw new Error("not empty");
3843
+ }
3844
+ } catch {
3845
+ throw new HTTPException(400, { message: "invalid request body" });
3846
+ }
3847
+ }
3848
+
3849
+ function requireUuidParam(context: Context, name: string): string {
3850
+ const value = context.req.param(name) ?? "";
3851
+ if (!isUuid(value)) throw new HTTPException(404, { message: "BrowserSession not found" });
3852
+ return value;
3853
+ }
3854
+
3855
+ function requireOpaqueParam(context: Context, name: string): string {
3856
+ const value = context.req.param(name) ?? "";
3857
+ if (value.length < 1 || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value)) {
3858
+ throw new HTTPException(404, { message: "browser target not found" });
3859
+ }
3860
+ return value;
3861
+ }
3862
+
3863
+ function diagnosticKinds(
3864
+ value: string | undefined,
3865
+ ): Array<ReturnType<typeof BrowserDiagnosticKind.parse>> | null {
3866
+ if (value === undefined || value === "") return null;
3867
+ const raw = [...new Set(value.split(","))];
3868
+ if (raw.length > 4 || raw.some((entry) => !entry)) {
3869
+ throw new HTTPException(400, { message: "invalid diagnostic kinds" });
3870
+ }
3871
+ const parsed = raw.map((entry) => BrowserDiagnosticKind.safeParse(entry));
3872
+ if (parsed.some((entry) => !entry.success)) {
3873
+ throw new HTTPException(400, { message: "invalid diagnostic kinds" });
3874
+ }
3875
+ return parsed.map((entry) => entry.data!);
3876
+ }
3877
+
3878
+ function optionalBoundedInteger(
3879
+ value: string | undefined,
3880
+ minimum: number,
3881
+ maximum: number,
3882
+ ): number | null {
3883
+ if (value === undefined || value === "") return null;
3884
+ if (!/^\d+$/u.test(value)) throw new HTTPException(400, { message: "invalid integer query" });
3885
+ const parsed = Number(value);
3886
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
3887
+ throw new HTTPException(400, { message: "integer query is out of range" });
3888
+ }
3889
+ return parsed;
3890
+ }
3891
+
3892
+ function isUuid(value: unknown): value is string {
3893
+ return (
3894
+ typeof value === "string" &&
3895
+ /^[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)
3896
+ );
3897
+ }
3898
+
3899
+ function requestOrigin(context: Context, allowedPattern: string): string | null {
3900
+ return validateBrowserRequestOrigin(context.req.header("origin"), allowedPattern);
3901
+ }
3902
+
3903
+ export function validateBrowserRequestOrigin(
3904
+ value: string | undefined,
3905
+ allowedPattern: string,
3906
+ ): string | null {
3907
+ if (!value) return null;
3908
+ let url: URL;
3909
+ try {
3910
+ url = new URL(value);
3911
+ } catch {
3912
+ throw new HTTPException(400, { message: "invalid request origin" });
3913
+ }
3914
+ if (
3915
+ url.origin === "null" ||
3916
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
3917
+ url.origin !== value
3918
+ ) {
3919
+ throw new HTTPException(400, { message: "invalid request origin" });
3920
+ }
3921
+ if (!allowedCorsOrigin(allowedPattern, url.origin)) {
3922
+ throw new HTTPException(403, { message: "request origin is not allowed" });
3923
+ }
3924
+ return url.origin;
3925
+ }
3926
+
3927
+ export function interactionActorForGrant(
3928
+ grant: AccessGrant,
3929
+ ): ReturnType<typeof InteractionActor.parse> {
3930
+ if (grant.principalKind !== "agent_attempt") {
3931
+ return InteractionActor.parse({
3932
+ kind: grant.principalKind === "service" ? "system" : "human",
3933
+ subjectId: grant.subjectId,
3934
+ });
3935
+ }
3936
+ return InteractionActor.parse({
3937
+ kind: "agent",
3938
+ subjectId: grant.subjectId,
3939
+ sessionId: grant.metadata?.["sessionId"],
3940
+ turnId: grant.metadata?.["turnId"],
3941
+ attemptId: grant.metadata?.["attemptId"],
3942
+ executionGeneration: grant.metadata?.["executionGeneration"],
3943
+ });
3944
+ }
3945
+
3946
+ function assertPlacementInstance(expected: string | null, actual: string): void {
3947
+ if (expected !== null && expected !== actual) {
3948
+ throw new BrowserSessionStateError("BrowserSession placement instance changed");
3949
+ }
3950
+ }
3951
+
3952
+ function isTerminalOperation(state: string): boolean {
3953
+ return state === "completed" || state === "failed" || state === "outcome_unknown";
3954
+ }
3955
+
3956
+ async function loadBoundBrowserCredential(
3957
+ deps: ApiRouteDeps,
3958
+ grant: AccessGrant,
3959
+ workspaceId: string,
3960
+ authority: Extract<SiteAuthAuthority, { kind: "connection_fields" }>,
3961
+ ) {
3962
+ return await loadExactInteractionCredential(deps, grant, workspaceId, authority.credential);
3963
+ }
3964
+
3965
+ async function loadExactInteractionCredential(
3966
+ deps: ApiRouteDeps,
3967
+ grant: AccessGrant,
3968
+ workspaceId: string,
3969
+ reference: InteractionCredentialAuthorityRef,
3970
+ ) {
3971
+ const credential = await loadConnectionCredentialForBroker(deps.db, deps.settings, {
3972
+ workspaceId,
3973
+ connectionId: reference.connectionId,
3974
+ providerDomain: reference.providerDomain,
3975
+ subjectId: reference.connectionSubjectId,
3976
+ allowSubjectOwned: reference.connectionSubjectId !== null,
3977
+ });
3978
+ if (
3979
+ !credential ||
3980
+ credential.id !== reference.connectionId ||
3981
+ credential.accountId !== grant.accountId ||
3982
+ credential.workspaceId !== workspaceId ||
3983
+ credential.subjectId !== reference.connectionSubjectId ||
3984
+ credential.providerDomain !== reference.providerDomain ||
3985
+ credential.status !== "active"
3986
+ ) {
3987
+ throw new BrowserAuthCredentialError("Configured browser credential is unavailable");
3988
+ }
3989
+ return credential;
3990
+ }
3991
+
3992
+ async function resolveBrowserNetworkRouteLaunch(input: {
3993
+ deps: ApiRouteDeps;
3994
+ grant: AccessGrant;
3995
+ workspaceId: string;
3996
+ browserSessionId: string;
3997
+ operationId: string;
3998
+ rootSecret: string;
3999
+ placement: InteractionPlacement;
4000
+ }): Promise<PlacementBrowserNetworkRoute | null> {
4001
+ const record = await getBrowserSessionControlRecord(input.deps.db, {
4002
+ accountId: input.grant.accountId,
4003
+ workspaceId: input.workspaceId,
4004
+ browserSessionId: input.browserSessionId,
4005
+ operationId: input.operationId,
4006
+ });
4007
+ const route = record.networkRouteAuthority;
4008
+ if (!route) return null;
4009
+ const kind = route.configuration.kind;
4010
+ const providerRoute =
4011
+ kind === "managed"
4012
+ ? managedNetworkRouteForPlacement(route.configuration, route.consistency, input.placement)
4013
+ : undefined;
4014
+ if (kind !== "managed" && input.placement.kind === "external_provider") {
4015
+ throw new BrowserSessionStateError(
4016
+ "External browser providers require a provider-managed NetworkRoute",
4017
+ );
4018
+ }
4019
+ let proxyCredential: { username: string; password: string } | null = null;
4020
+ let credentialVersion: number | null = null;
4021
+ let proxyUrl: string | undefined;
4022
+ const dispatched = record.operation?.state === "dispatched";
4023
+ if (kind === "proxy") {
4024
+ const reference = route.configuration.credential;
4025
+ if (reference) {
4026
+ try {
4027
+ const credential = await loadExactInteractionCredential(
4028
+ input.deps,
4029
+ input.grant,
4030
+ input.workspaceId,
4031
+ reference,
4032
+ );
4033
+ credentialVersion = credential.version;
4034
+ proxyCredential = proxyCredentialFromBundle(credential.credential);
4035
+ } catch (error) {
4036
+ if (route.authorityDigest && dispatched) {
4037
+ return secretlessNetworkRouteReplay(route, kind, providerRoute);
4038
+ }
4039
+ throw error;
4040
+ }
4041
+ if (dispatched && route.credentialVersion !== credentialVersion) {
4042
+ return secretlessNetworkRouteReplay(route, kind, providerRoute);
4043
+ }
4044
+ }
4045
+ proxyUrl = browserProxyUrl(route.configuration, proxyCredential);
4046
+ }
4047
+ if (dispatched) {
4048
+ if (!route.authorityDigest) {
4049
+ throw new BrowserSessionStateError("BrowserSession route authority is not bound");
4050
+ }
4051
+ return {
4052
+ routeId: route.routeId,
4053
+ routeVersion: route.routeVersion,
4054
+ authorityDigest: route.authorityDigest,
4055
+ kind,
4056
+ consistency: route.consistency,
4057
+ ...(proxyUrl === undefined ? {} : { proxyUrl }),
4058
+ ...(providerRoute === undefined ? {} : { providerRoute }),
4059
+ };
4060
+ }
4061
+ const authorityDigest = deriveBrowserNetworkRouteAuthorityDigest({
4062
+ rootSecret: input.rootSecret,
4063
+ accountId: input.grant.accountId,
4064
+ workspaceId: input.workspaceId,
4065
+ browserSessionId: input.browserSessionId,
4066
+ routeId: route.routeId,
4067
+ routeVersion: route.routeVersion,
4068
+ credentialVersion,
4069
+ configuration: route.configuration,
4070
+ consistency: route.consistency,
4071
+ proxyCredential,
4072
+ });
4073
+ const bound = await bindBrowserSessionNetworkRouteAuthority(input.deps.db, {
4074
+ accountId: input.grant.accountId,
4075
+ workspaceId: input.workspaceId,
4076
+ browserSessionId: input.browserSessionId,
4077
+ operationId: input.operationId,
4078
+ routeVersion: route.routeVersion,
4079
+ credentialVersion,
4080
+ authorityDigest,
4081
+ });
4082
+ if (bound.authorityDigest !== authorityDigest) {
4083
+ throw new BrowserSessionOperationConflictError("BrowserSession route launch authority changed");
4084
+ }
4085
+ return {
4086
+ routeId: route.routeId,
4087
+ routeVersion: route.routeVersion,
4088
+ authorityDigest,
4089
+ kind,
4090
+ consistency: route.consistency,
4091
+ ...(proxyUrl === undefined ? {} : { proxyUrl }),
4092
+ ...(providerRoute === undefined ? {} : { providerRoute }),
4093
+ };
4094
+ }
4095
+
4096
+ async function resolveActiveBrowserNetworkRouteLaunch(input: {
4097
+ deps: ApiRouteDeps;
4098
+ grant: AccessGrant;
4099
+ record: BrowserSessionControlRecord;
4100
+ placement: InteractionPlacement;
4101
+ }): Promise<PlacementBrowserNetworkRoute | null> {
4102
+ const route = input.record.networkRouteAuthority;
4103
+ if (!route) return null;
4104
+ if (!route.authorityDigest) {
4105
+ throw new BrowserSessionStateError("BrowserSession route authority is not bound");
4106
+ }
4107
+ const kind = route.configuration.kind;
4108
+ const providerRoute =
4109
+ kind === "managed"
4110
+ ? managedNetworkRouteForPlacement(route.configuration, route.consistency, input.placement)
4111
+ : undefined;
4112
+ if (kind !== "managed" && input.placement.kind === "external_provider") {
4113
+ throw new BrowserSessionStateError(
4114
+ "External browser providers require a provider-managed NetworkRoute",
4115
+ );
4116
+ }
4117
+ let proxyUrl: string | undefined;
4118
+ if (kind === "proxy") {
4119
+ const reference = route.configuration.credential;
4120
+ let proxyCredential: { username: string; password: string } | null = null;
4121
+ if (reference) {
4122
+ const credential = await loadExactInteractionCredential(
4123
+ input.deps,
4124
+ input.grant,
4125
+ input.record.session.workspaceId,
4126
+ reference,
4127
+ );
4128
+ if (credential.version !== route.credentialVersion) {
4129
+ throw new BrowserSessionStateError(
4130
+ "BrowserSession proxy credential changed; resume the browser to reconnect safely",
4131
+ );
4132
+ }
4133
+ proxyCredential = proxyCredentialFromBundle(credential.credential);
4134
+ }
4135
+ proxyUrl = browserProxyUrl(route.configuration, proxyCredential);
4136
+ }
4137
+ return {
4138
+ routeId: route.routeId,
4139
+ routeVersion: route.routeVersion,
4140
+ authorityDigest: route.authorityDigest,
4141
+ kind,
4142
+ consistency: route.consistency,
4143
+ ...(proxyUrl === undefined ? {} : { proxyUrl }),
4144
+ ...(providerRoute === undefined ? {} : { providerRoute }),
4145
+ };
4146
+ }
4147
+
4148
+ function secretlessNetworkRouteReplay(
4149
+ route: NonNullable<BrowserSessionControlRecord["networkRouteAuthority"]>,
4150
+ kind: "direct" | "proxy" | "managed" | "tunnel",
4151
+ providerRoute?: NonNullable<PlacementBrowserNetworkRoute["providerRoute"]>,
4152
+ ): PlacementBrowserNetworkRoute {
4153
+ if (!route.authorityDigest) {
4154
+ throw new BrowserSessionStateError("BrowserSession route authority is not bound");
4155
+ }
4156
+ return {
4157
+ routeId: route.routeId,
4158
+ routeVersion: route.routeVersion,
4159
+ authorityDigest: route.authorityDigest,
4160
+ kind,
4161
+ consistency: route.consistency,
4162
+ ...(providerRoute === undefined ? {} : { providerRoute }),
4163
+ };
4164
+ }
4165
+
4166
+ function proxyCredentialFromBundle(credential: Readonly<Record<string, unknown>>): {
4167
+ username: string;
4168
+ password: string;
4169
+ } {
4170
+ const username = credential.username;
4171
+ const password = credential.password;
4172
+ if (
4173
+ typeof username !== "string" ||
4174
+ Buffer.byteLength(username) < 1 ||
4175
+ Buffer.byteLength(username) > 4_096 ||
4176
+ username.includes("\0") ||
4177
+ typeof password !== "string" ||
4178
+ Buffer.byteLength(password) < 1 ||
4179
+ Buffer.byteLength(password) > 16_384 ||
4180
+ password.includes("\0")
4181
+ ) {
4182
+ throw new BrowserAuthCredentialError(
4183
+ "Proxy credential must contain bounded username and password fields",
4184
+ );
4185
+ }
4186
+ return { username, password };
4187
+ }
4188
+
4189
+ function browserProxyUrl(
4190
+ configuration: Extract<
4191
+ NonNullable<BrowserSessionControlRecord["networkRouteAuthority"]>["configuration"],
4192
+ { kind: "proxy" }
4193
+ >,
4194
+ credential: { username: string; password: string } | null,
4195
+ ): string {
4196
+ const rawHost = configuration.host;
4197
+ if (
4198
+ /[\s/@?#]/u.test(rawHost) ||
4199
+ ((rawHost.includes("[") || rawHost.includes("]")) && !/^\[[^\]]+\]$/u.test(rawHost))
4200
+ ) {
4201
+ throw new BrowserSessionStateError("Network route proxy host is invalid");
4202
+ }
4203
+ const host = rawHost.includes(":") ? `[${rawHost.replace(/^\[|\]$/gu, "")}]` : rawHost;
4204
+ let url: URL;
4205
+ try {
4206
+ url = new URL(`${configuration.protocol}://${host}:${configuration.port}`);
4207
+ } catch {
4208
+ throw new BrowserSessionStateError("Network route proxy host is invalid");
4209
+ }
4210
+ if (!url.hostname || url.pathname !== "/") {
4211
+ throw new BrowserSessionStateError("Network route proxy host is invalid");
4212
+ }
4213
+ if (credential) {
4214
+ url.username = credential.username;
4215
+ url.password = credential.password;
4216
+ }
4217
+ return url.toString();
4218
+ }
4219
+
4220
+ async function settleProtectedAuthReceipt(input: {
4221
+ deps: ApiRouteDeps;
4222
+ scope: {
4223
+ accountId: string;
4224
+ workspaceId: string;
4225
+ actorSubjectId: string;
4226
+ authRunId: string;
4227
+ };
4228
+ receipt: BrowserProtectedAuthFillReceiptValue;
4229
+ }) {
4230
+ const { deps, scope, receipt } = input;
4231
+ if (receipt.state === "completed") {
4232
+ if (!receipt.observation) {
4233
+ throw new BrowserControlProtocolError("protected-fill receipt omitted its observation");
4234
+ }
4235
+ return await completeProtectedAuthFill(deps.db, {
4236
+ ...scope,
4237
+ operationId: receipt.operationId,
4238
+ status: receipt.observation.status,
4239
+ targetGeneration: receipt.observation.target.targetGeneration,
4240
+ documentGeneration: receipt.observation.target.documentGeneration,
4241
+ });
4242
+ }
4243
+ if (receipt.state === "failed") {
4244
+ if (!receipt.error) {
4245
+ throw new BrowserControlProtocolError("protected-fill failure omitted its error");
4246
+ }
4247
+ const staleCodes = new Set([
4248
+ "controller_stale",
4249
+ "target_not_found",
4250
+ "target_stale",
4251
+ "observation_stale",
4252
+ "document_stale",
4253
+ "frame_stale",
4254
+ "locator_not_found",
4255
+ "locator_ambiguous",
4256
+ ]);
4257
+ return await completeProtectedAuthFill(deps.db, {
4258
+ ...scope,
4259
+ operationId: receipt.operationId,
4260
+ status: staleCodes.has(receipt.error.code) ? "stale" : "failed",
4261
+ failureCode: receipt.error.code,
4262
+ });
4263
+ }
4264
+ if (receipt.state === "outcome_unknown") {
4265
+ await markProtectedAuthFillOutcomeUnknown(deps.db, {
4266
+ ...scope,
4267
+ operationId: receipt.operationId,
4268
+ errorCode: receipt.error?.code ?? "outcome_unknown",
4269
+ });
4270
+ throw new InteractionResourceStateError("Protected-fill outcome is unknown");
4271
+ }
4272
+ throw new InteractionResourceStateError(`Protected-fill operation is still ${receipt.state}`);
4273
+ }
4274
+
4275
+ function assertAuthRunBrowser(actual: string, expected: string): void {
4276
+ if (actual !== expected) {
4277
+ throw new InteractionResourceConflictError("Auth run belongs to another browser session");
4278
+ }
4279
+ }
4280
+
4281
+ function browserUploadFileIds(action: BrowserActionRequestValue["action"]): string[] {
4282
+ const actions = action.type === "batch" ? action.actions : [action];
4283
+ return [
4284
+ ...new Set(actions.flatMap((entry) => (entry.type === "upload" ? entry.workspaceFileIds : []))),
4285
+ ];
4286
+ }
4287
+
4288
+ function browserUploadFilename(value: string): string {
4289
+ const normalized = value
4290
+ .trim()
4291
+ .replace(/[/\\]/gu, "_")
4292
+ .replace(/[^A-Za-z0-9._ -]+/gu, "_")
4293
+ .replace(/\s+/gu, " ")
4294
+ .trim()
4295
+ .slice(0, 240);
4296
+ return normalized && normalized !== "." && normalized !== ".." ? normalized : "file";
4297
+ }
4298
+
4299
+ function browserUploadSha256(value: string | null): string | null {
4300
+ return value && /^[0-9a-f]{64}$/u.test(value) ? value : null;
4301
+ }
4302
+
4303
+ function interactionFailure(error: unknown) {
4304
+ if (error instanceof BrowserControlRequestError) return error.error;
4305
+ if (
4306
+ error instanceof BrowserControlUnsupportedError ||
4307
+ error instanceof BrowserControlServerUnsupportedError
4308
+ ) {
4309
+ return {
4310
+ code: "unsupported" as const,
4311
+ message: error.message,
4312
+ retryable: false,
4313
+ };
4314
+ }
4315
+ if (error instanceof BrowserControlServerError) {
4316
+ return {
4317
+ code:
4318
+ error.stage === "engine_unavailable"
4319
+ ? ("unsupported" as const)
4320
+ : ("driver_failed" as const),
4321
+ message: error.message,
4322
+ retryable: error.stage !== "engine_unavailable",
4323
+ };
4324
+ }
4325
+ return {
4326
+ code: "driver_failed" as const,
4327
+ message: error instanceof Error ? error.message : "browser controller failed",
4328
+ retryable: false,
4329
+ };
4330
+ }
4331
+
4332
+ function assertBrowserDownloadSaveSource(
4333
+ current: BrowserDownloadValue,
4334
+ expected: BrowserDownloadValue,
4335
+ ): void {
4336
+ if (
4337
+ current.id !== expected.id ||
4338
+ current.browserSessionId !== expected.browserSessionId ||
4339
+ current.controllerGeneration !== expected.controllerGeneration ||
4340
+ current.status !== "completed" ||
4341
+ current.version !== expected.version ||
4342
+ current.filename !== expected.filename ||
4343
+ current.receivedBytes !== expected.receivedBytes ||
4344
+ current.totalBytes !== expected.totalBytes ||
4345
+ current.sha256 !== expected.sha256
4346
+ ) {
4347
+ throw new InteractionResourceStateError("Browser download changed before it was saved");
4348
+ }
4349
+ }
4350
+
4351
+ async function finalizeBrowserDownloadFile(
4352
+ deps: ApiRouteDeps,
4353
+ grant: AccessGrant,
4354
+ workspaceId: string,
4355
+ uploadId: string,
4356
+ ): Promise<NonNullable<Awaited<ReturnType<typeof getFileUpload>>>> {
4357
+ const objectStorage = deps.objectStorage;
4358
+ if (!objectStorage) throw new HTTPException(503, { message: "object storage is not configured" });
4359
+ let upload = await getFileUpload(deps.db, workspaceId, uploadId);
4360
+ if (!upload) throw new InteractionResourceStateError("Browser download file upload is absent");
4361
+ if (upload.status === "completed" && upload.file.status === "ready") {
4362
+ await recordBrowserDownloadFileUsage(deps, grant, workspaceId, upload.file);
4363
+ return upload;
4364
+ }
4365
+ if (upload.status !== "pending") {
4366
+ throw new InteractionResourceStateError(
4367
+ `Browser download file upload is ${upload.status.replace("_", " ")}`,
4368
+ );
4369
+ }
4370
+ if (upload.expiresAt.getTime() <= Date.now()) {
4371
+ throw new InteractionResourceStateError("Browser download file upload authority expired");
4372
+ }
4373
+ const head = await objectStorage.headFile(upload.file).catch(() => {
4374
+ throw new HTTPException(503, { message: "browser download object is not available" });
4375
+ });
4376
+ if (
4377
+ Number(head.ContentLength ?? -1) !== upload.file.sizeBytes ||
4378
+ (head.ContentType !== undefined && head.ContentType !== upload.file.contentType) ||
4379
+ (upload.file.sha256 !== null && head.Metadata?.sha256 !== upload.file.sha256)
4380
+ ) {
4381
+ throw new HTTPException(502, { message: "browser download object failed verification" });
4382
+ }
4383
+ let file: FileAsset;
4384
+ try {
4385
+ file = await completeFileUpload(deps.db, workspaceId, upload.id);
4386
+ } catch (error) {
4387
+ const current = await getFileUpload(deps.db, workspaceId, upload.id);
4388
+ if (current?.status !== "completed" || current.file.status !== "ready") throw error;
4389
+ upload = current;
4390
+ file = current.file;
4391
+ }
4392
+ await recordBrowserDownloadFileUsage(deps, grant, workspaceId, file);
4393
+ return { ...upload, status: "completed", file };
4394
+ }
4395
+
4396
+ async function recordBrowserDownloadFileUsage(
4397
+ deps: ApiRouteDeps,
4398
+ grant: AccessGrant,
4399
+ workspaceId: string,
4400
+ file: FileAsset,
4401
+ ): Promise<void> {
4402
+ await recordWorkspaceUsage(deps, {
4403
+ accountId: grant.accountId,
4404
+ workspaceId,
4405
+ subjectId: grant.subjectId,
4406
+ eventType: "file.uploaded",
4407
+ quantity: file.sizeBytes,
4408
+ unit: "byte",
4409
+ sourceResourceType: "file",
4410
+ sourceResourceId: file.id,
4411
+ idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`,
4412
+ });
4413
+ }
4414
+
4415
+ function browserRouteError(error: unknown): HTTPException {
4416
+ if (error instanceof HTTPException) return error;
4417
+ if (error instanceof BrowserSessionNotFoundError) {
4418
+ return new HTTPException(404, { message: error.message, cause: error });
4419
+ }
4420
+ if (error instanceof AttachedBrowserDeviceNotFoundError) {
4421
+ return new HTTPException(404, { message: error.message, cause: error });
4422
+ }
4423
+ if (error instanceof BrowserIdentityNotFoundError) {
4424
+ return new HTTPException(404, { message: error.message, cause: error });
4425
+ }
4426
+ if (error instanceof InteractionResourceNotFoundError) {
4427
+ return new HTTPException(404, { message: error.message, cause: error });
4428
+ }
4429
+ if (
4430
+ error instanceof BrowserSessionOperationConflictError ||
4431
+ error instanceof BrowserSessionStateError ||
4432
+ error instanceof BrowserIdentityConflictError ||
4433
+ error instanceof BrowserIdentityStateError ||
4434
+ error instanceof BrowserStateUploadStateError ||
4435
+ error instanceof InteractionResourceConflictError ||
4436
+ error instanceof InteractionResourceStateError ||
4437
+ error instanceof BrowserAuthCredentialError
4438
+ ) {
4439
+ return new HTTPException(409, { message: error.message, cause: error });
4440
+ }
4441
+ if (error instanceof BrowserControlRequestError) {
4442
+ return new HTTPException(error.status as ContentfulStatusCode, {
4443
+ message: error.error.message,
4444
+ cause: error,
4445
+ });
4446
+ }
4447
+ if (error instanceof BrowserControlTransportError) {
4448
+ return new HTTPException(503, {
4449
+ message: "browser controller is unavailable",
4450
+ cause: error,
4451
+ });
4452
+ }
4453
+ if (error instanceof BrowserControlProtocolError) {
4454
+ return new HTTPException(502, {
4455
+ message: "browser controller response is invalid",
4456
+ cause: error,
4457
+ });
4458
+ }
4459
+ if (
4460
+ error instanceof BrowserControlUnsupportedError ||
4461
+ error instanceof BrowserControlServerUnsupportedError
4462
+ ) {
4463
+ return new HTTPException(409, { message: error.message, cause: error });
4464
+ }
4465
+ if (error instanceof BrowserControlServerError) {
4466
+ return new HTTPException(error.stage === "engine_unavailable" ? 409 : 503, {
4467
+ message:
4468
+ error.stage === "engine_unavailable"
4469
+ ? "browser engine is unavailable on this placement"
4470
+ : "browser controller could not start",
4471
+ cause: error,
4472
+ });
4473
+ }
4474
+ return new HTTPException(500, {
4475
+ message: "BrowserSession request failed",
4476
+ cause: error,
4477
+ });
4478
+ }
4479
+
4480
+ function isAbort(error: unknown): boolean {
4481
+ return error instanceof Error && error.name === "AbortError";
4482
+ }