@opengeni/api-router 0.26.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 (87) hide show
  1. package/dist/app.d.ts +1 -0
  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 +19 -1
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
  8. package/dist/chunk-2PQMSRCB.js.map +1 -0
  9. package/dist/controller-data-plane.d.ts +12 -0
  10. package/dist/http/auth.d.ts +1 -1
  11. package/dist/http/sse.d.ts +18 -1
  12. package/dist/index.js +1 -1
  13. package/dist/integrations/api-integrations.d.ts +2 -2
  14. package/dist/integrations/fiken.d.ts +233 -0
  15. package/dist/integrations/google-drive.d.ts +6 -6
  16. package/dist/integrations/provider-oauth.d.ts +0 -2
  17. package/dist/integrations/slack-bot.d.ts +171 -1
  18. package/dist/integrations/slack-interactions.d.ts +17 -5
  19. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  20. package/dist/interaction-metrics.d.ts +11 -0
  21. package/dist/mcp/server.d.ts +3 -2
  22. package/dist/model-catalog.d.ts +1 -0
  23. package/dist/routes/canonical-human-identities.d.ts +3 -0
  24. package/dist/routes/channels.d.ts +3 -0
  25. package/dist/routes/integration-facets.d.ts +3 -0
  26. package/dist/routes/interaction-resources.d.ts +5 -0
  27. package/dist/routes/organization-memberships.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +2 -1
  29. package/dist/routes/slack-task-policy.d.ts +3 -0
  30. package/dist/routes/supergrok.d.ts +3 -0
  31. package/dist/sandbox/channel-a.d.ts +8 -1
  32. package/dist/sandbox/machines.d.ts +2 -2
  33. package/dist/sandbox/viewer.d.ts +3 -1
  34. package/dist/slack-reaction-files.d.ts +27 -0
  35. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  36. package/dist/xai-realtime.d.ts +26 -0
  37. package/dist/xai-subscription-auth.d.ts +23 -0
  38. package/package.json +17 -16
  39. package/src/app.ts +352 -49
  40. package/src/auth/canonical-human-session-admission.ts +116 -0
  41. package/src/auth/managed-auth.ts +101 -0
  42. package/src/browser-auth-broker.ts +155 -0
  43. package/src/browser-controller-authority.ts +50 -1
  44. package/src/browser-network-route.ts +34 -0
  45. package/src/controller-data-plane.ts +35 -0
  46. package/src/http/auth.ts +27 -9
  47. package/src/http/sse.ts +229 -5
  48. package/src/integrations/api-integrations.ts +57 -42
  49. package/src/integrations/fiken.ts +1230 -0
  50. package/src/integrations/google-drive.ts +31 -26
  51. package/src/integrations/provider-oauth.ts +70 -91
  52. package/src/integrations/slack-bot.ts +638 -9
  53. package/src/integrations/slack-interactions.ts +1583 -73
  54. package/src/interaction-holder-heartbeat.ts +95 -0
  55. package/src/interaction-metrics.ts +159 -0
  56. package/src/mcp/documents.ts +115 -6
  57. package/src/mcp/server.ts +865 -242
  58. package/src/model-catalog.ts +19 -6
  59. package/src/routes/api-integrations.ts +32 -29
  60. package/src/routes/browser-identities.ts +24 -0
  61. package/src/routes/browser-sessions.ts +2122 -183
  62. package/src/routes/canonical-human-identities.ts +156 -0
  63. package/src/routes/channels.ts +90 -0
  64. package/src/routes/computer-sessions.ts +441 -86
  65. package/src/routes/connections.ts +141 -4
  66. package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
  67. package/src/routes/interaction-resources.ts +595 -0
  68. package/src/routes/organization-memberships.ts +53 -0
  69. package/src/routes/plugins.ts +2 -1
  70. package/src/routes/sessions.ts +419 -43
  71. package/src/routes/skills.ts +92 -10
  72. package/src/routes/slack-task-policy.ts +115 -0
  73. package/src/routes/supergrok.ts +717 -0
  74. package/src/routes/transcription-recordings.ts +9 -2
  75. package/src/routes/transcriptions.ts +2 -1
  76. package/src/routes/video-generation.ts +34 -4
  77. package/src/routes/workspaces.ts +22 -4
  78. package/src/sandbox/channel-a.ts +29 -2
  79. package/src/sandbox/machines.ts +5 -5
  80. package/src/sandbox/viewer.ts +10 -3
  81. package/src/slack-reaction-files.ts +181 -0
  82. package/src/transcription/providers/xai-subscription.ts +110 -0
  83. package/src/transcription/service.ts +17 -2
  84. package/src/xai-realtime.ts +216 -0
  85. package/src/xai-subscription-auth.ts +132 -0
  86. package/dist/chunk-JIKNR5YL.js.map +0 -1
  87. package/dist/routes/integration-features.d.ts +0 -3
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
9
9
  BROWSER_CONTROL_WEBSOCKET_PROTOCOL,
10
+ BROWSER_CONTROL_PORT,
10
11
  BROWSER_CONTROL_PROTOCOL_VERSION,
11
12
  BROWSER_PROFILE_ARTIFACT_FORMAT,
12
13
  BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
@@ -15,7 +16,14 @@ import {
15
16
  BrowserActionCommand,
16
17
  BrowserActionRequest,
17
18
  BrowserDiagnosticKind,
19
+ BrowserDownload,
20
+ BrowserDownloadSaveRequest,
21
+ BrowserDownloadSaveResponse,
22
+ BrowserDownloadListResponse,
23
+ BrowserExternalAuthCommand,
18
24
  BrowserOpenTargetRequest,
25
+ BrowserObservation,
26
+ BrowserProtectedAuthFillCommand,
19
27
  BrowserSessionAttachment,
20
28
  BrowserSessionAttachmentRequest,
21
29
  BrowserSessionHeartbeatResponse,
@@ -24,56 +32,105 @@ import {
24
32
  BrowserSessionMutationResponse,
25
33
  BrowserTargetListResponse,
26
34
  CreateBrowserSessionRequest,
35
+ ExternalAuthInteractiveRequest,
36
+ ExternalAuthInteractiveResponse,
37
+ ExternalAuthRunRequest,
38
+ ExternalAuthRunResponse,
27
39
  InteractionActor,
40
+ ProtectedAuthFillRequest,
41
+ ProtectedAuthFillResponse,
42
+ ReportAuthRunRequest,
43
+ StartAuthRunRequest,
44
+ VerifyAuthRunRequest,
28
45
  PublishBrowserRevisionRequest,
29
46
  PublishBrowserRevisionResponse,
30
47
  type AccessGrant,
48
+ type BrowserProtectedAuthFillReceipt as BrowserProtectedAuthFillReceiptValue,
49
+ type BrowserActionRequest as BrowserActionRequestValue,
50
+ type BrowserDownload as BrowserDownloadValue,
31
51
  type BrowserSession as BrowserSessionValue,
52
+ type FileAsset,
32
53
  type CreateBrowserSessionRequest as CreateBrowserSessionRequestValue,
33
54
  type InteractionPlacement,
55
+ type InteractionCredentialAuthorityRef,
34
56
  type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
35
57
  type Session,
36
58
  type SessionAuthorizationOperation,
59
+ type SiteAuthAuthority,
37
60
  } from "@opengeni/contracts";
38
61
  import {
39
62
  acquireLease,
40
63
  activateBrowserSession,
41
64
  ATTACHED_BROWSER_SESSION_CAPABILITIES,
65
+ EXTERNAL_BROWSER_SESSION_CAPABILITIES,
66
+ LIGHTPANDA_BROWSER_SESSION_CAPABILITIES,
67
+ bindBrowserSessionNetworkRouteAuthority,
42
68
  AttachedBrowserDeviceNotFoundError,
43
69
  BrowserIdentityConflictError,
44
70
  BrowserIdentityNotFoundError,
45
71
  BrowserIdentityStateError,
72
+ BrowserStateUploadStateError,
46
73
  clearSuspendedBrowserSessionController,
47
74
  BrowserSessionNotFoundError,
48
75
  BrowserSessionOperationConflictError,
49
76
  BrowserSessionStateError,
50
77
  completeBrowserSessionEnd,
78
+ completeExternalAuth,
79
+ completeBrowserDownloadSave,
80
+ completeFileUpload,
81
+ completeProtectedAuthFill,
51
82
  commitBrowserSessionSuspension,
52
83
  commitBrowserRevisionPublication,
53
84
  dispatchBrowserRevisionPublication,
85
+ dispatchBrowserDownloadSave,
54
86
  dispatchBrowserSessionOperation,
87
+ dispatchExternalAuth,
88
+ dispatchProtectedAuthFill,
55
89
  failBrowserSessionOperation,
56
90
  failBrowserSessionResume,
57
91
  failBrowserSessionResumePreparation,
58
92
  failBrowserSessionSuspension,
59
93
  failBrowserRevisionPublication,
60
94
  findBrowserSessionControlRecordByOperation,
95
+ findBrowserDownloadSave,
61
96
  getBrowserSessionControlRecord,
62
97
  getComputerSessionControlRecord,
63
98
  getBrowserPrivateCheckpointAuthority,
64
99
  getAttachedBrowserDevice,
65
100
  getBrowserRevisionArtifactAuthority,
66
101
  getEnrollment,
102
+ getExternalAuthInteractiveContext,
103
+ getFiles,
104
+ getFileUpload,
105
+ getAuthRun,
106
+ getExternalAuthPreparation,
107
+ getProtectedAuthFillPreparation,
108
+ getSiteAuthConnection,
67
109
  getSession,
110
+ InteractionResourceConflictError,
111
+ InteractionResourceNotFoundError,
112
+ InteractionResourceStateError,
113
+ listAuthRuns,
68
114
  listBrowserSessions,
69
115
  listAttachedBrowserDevices,
70
116
  prepareBrowserSessionCreate,
117
+ prepareExternalAuth,
118
+ prepareBrowserDownloadSave,
71
119
  prepareBrowserSessionEnd,
72
120
  prepareBrowserSessionResume,
73
121
  prepareBrowserSessionSuspend,
74
122
  prepareBrowserRevisionPublication,
123
+ prepareProtectedAuthFill,
124
+ reportAuthRun,
75
125
  releaseLeaseHolder,
126
+ readLease,
127
+ recordLeaseControllerDataPlaneUrl,
128
+ loadConnectionCredentialForBroker,
129
+ markProtectedAuthFillOutcomeUnknown,
130
+ startAuthRun,
76
131
  touchBrowserSessionController,
132
+ verifyAuthRun,
133
+ settleBrowserDownloadSaveFailure,
77
134
  type BrowserSessionControlRecord,
78
135
  type BrowserPrivateCheckpointAuthority,
79
136
  type BrowserRevisionArtifactAuthority,
@@ -82,6 +139,8 @@ import {
82
139
  import {
83
140
  requireAccessGrant,
84
141
  requireSessionAuthorization,
142
+ recordWorkspaceUsage,
143
+ requireLimit,
85
144
  relayConfigFromSettings,
86
145
  SessionAuthorizationDeniedError,
87
146
  SessionAuthorizationUnavailableError,
@@ -89,20 +148,22 @@ import {
89
148
  } from "@opengeni/core";
90
149
  import {
91
150
  BrowserControlProtocolError,
151
+ BrowserControlClient,
92
152
  BrowserControlRequestError,
93
153
  BrowserControlServerError,
94
154
  BrowserControlServerUnsupportedError,
95
155
  BrowserControlTransportError,
96
156
  BrowserControlUnsupportedError,
97
157
  buildStreamUrl,
158
+ exposedPortEndpointFromUrl,
98
159
  buildSelfhostedBackendSession,
99
160
  mintStreamToken,
100
161
  NatsControlRpc,
101
162
  NatsOpStreamTransport,
102
163
  provisionBrowserControlClient,
103
- type BrowserControlClient,
104
164
  type BrowserControlPlacementSession,
105
165
  type PlacementBrowserStateCaptureReceipt,
166
+ type PlacementBrowserNetworkRoute,
106
167
  type PlacementBrowserTransport,
107
168
  type RestorePlacementBrowserStateInput,
108
169
  } from "@opengeni/runtime/sandbox";
@@ -111,9 +172,12 @@ import { HTTPException } from "hono/http-exception";
111
172
  import type { ContentfulStatusCode } from "hono/utils/http-status";
112
173
  import {
113
174
  deriveBrowserControllerAdminToken,
175
+ deriveBrowserNetworkRouteAuthorityDigest,
114
176
  deriveBrowserSessionControllerTokens,
115
177
  deriveBrowserViewGrantToken,
116
178
  } from "../browser-controller-authority";
179
+ import { withCachedController } from "../controller-data-plane";
180
+ import { withInteractionHolderHeartbeat } from "../interaction-holder-heartbeat";
117
181
  import {
118
182
  browserStateArtifactAad,
119
183
  browserStateManifestDigest,
@@ -122,13 +186,30 @@ import {
122
186
  unwrapBrowserStateDataKey,
123
187
  wrapBrowserStateDataKey,
124
188
  } from "../browser-state-authority";
189
+ import {
190
+ BrowserAuthCredentialError,
191
+ resolveProtectedAuthFieldValues,
192
+ } from "../browser-auth-broker";
193
+ import { managedNetworkRouteForPlacement } from "../browser-network-route";
125
194
  import { allowedCorsOrigin } from "../http/cors";
126
- import { withChannelA, type ChannelAOperation } from "../sandbox/channel-a";
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";
127
203
 
128
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;
129
209
 
130
210
  type BrowserPlacement = {
131
211
  placement: InteractionPlacement;
212
+ controllerHostSandboxGroupId: string | null;
132
213
  placementInstanceId: string;
133
214
  session: BrowserControlPlacementSession;
134
215
  lease: LeaseSnapshot | null;
@@ -214,6 +295,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
214
295
  const workspaceId = context.req.param("workspaceId");
215
296
  const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
216
297
  const request = await parseJsonBody(context, CreateBrowserSessionRequest);
298
+ const startedAtMs = performance.now();
217
299
  await authorizeSourceSession(deps, grant, request.sessionId, "session.control");
218
300
  const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
219
301
  const authority = browserAuthorityRoot(deps);
@@ -237,7 +319,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
237
319
  )
238
320
  : null;
239
321
  if (prepared && isTerminalOperation(prepared.operation.state)) {
240
- return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
322
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
323
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
324
+ return context.json(parsed, 200);
241
325
  }
242
326
 
243
327
  const sourceSession = await requireSourceSession(deps, workspaceId, request.sessionId);
@@ -278,6 +362,15 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
278
362
  );
279
363
 
280
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
+ });
281
374
  const interactionHeld = await ensureInteractionHolder(
282
375
  deps,
283
376
  grant,
@@ -293,7 +386,19 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
293
386
  prepared.session.id,
294
387
  request.operationId,
295
388
  placement.placementInstanceId,
296
- );
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;
297
402
  const controllerGeneration = requireOperationGeneration(record);
298
403
  const linkedComputer = await requireLinkedComputerBinding(
299
404
  deps,
@@ -319,22 +424,43 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
319
424
  tokenGeneration: record.tokenGeneration,
320
425
  });
321
426
  try {
322
- const { client } = await provisionBrowserControlClient(placement.session, {
427
+ await withBrowserCreationController(
428
+ deps,
429
+ grant,
430
+ workspaceId,
431
+ placement,
323
432
  adminToken,
324
- nativeAuthority: nativeBrowserControllerAuthority(workspaceId, placement),
325
- ...(origin ? { allowedOrigins: [origin] } : {}),
326
- });
327
- await client.createSession({
328
- browserSessionId: prepared.session.id,
329
- controllerGeneration,
330
- tokenGeneration: record.tokenGeneration,
331
- ...tokens,
332
- headed: !prepared.session.headless,
333
- transport: placement.transport,
334
- ...(linkedComputer ? { linkedComputer } : {}),
335
- ...(request.initialUrl ? { initialUrl: request.initialUrl } : {}),
336
- ...(restore ? { restore } : {}),
337
- });
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
+ );
338
464
  } catch (error) {
339
465
  if (
340
466
  error instanceof BrowserControlTransportError ||
@@ -359,7 +485,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
359
485
  grant,
360
486
  workspaceId,
361
487
  prepared.session.id,
362
- placement.placement,
488
+ placement.controllerHostSandboxGroupId,
363
489
  ).catch(() => undefined);
364
490
  }
365
491
  return failed;
@@ -386,6 +512,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
386
512
  },
387
513
  );
388
514
  const parsed = BrowserSessionMutationResponse.parse(response);
515
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
389
516
  return context.json(
390
517
  parsed,
391
518
  parsed.operation.state === "completed" && !parsed.operation.replayed ? 201 : 200,
@@ -435,7 +562,8 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
435
562
  browserSessionId,
436
563
  "session.control",
437
564
  "browser.control",
438
- async ({ sessionClient }) => await sessionClient.openTarget(request.url),
565
+ async ({ sessionClient }) =>
566
+ BrowserObservation.parse(await sessionClient.openTarget(request.url)),
439
567
  );
440
568
  return context.json(result, 201);
441
569
  },
@@ -457,20 +585,814 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
457
585
  browserSessionId,
458
586
  "session.control",
459
587
  "browser.control",
460
- async ({ sessionClient }) => await sessionClient.selectTarget(targetId),
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),
461
905
  );
462
906
  return context.json(result);
463
907
  },
464
908
  );
465
909
 
466
- app.delete(
467
- "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId",
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",
468
1365
  async (context) => {
469
1366
  const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
470
1367
  context,
471
1368
  "sessions:control",
472
1369
  );
473
- const targetId = requireOpaqueParam(context, "targetId");
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
+ }
474
1396
  const result = await withActiveBrowserController(
475
1397
  context,
476
1398
  grant,
@@ -478,46 +1400,179 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
478
1400
  browserSessionId,
479
1401
  "session.control",
480
1402
  "browser.control",
481
- async ({ sessionClient, binding }) =>
482
- BrowserTargetListResponse.parse({
483
- browserSessionId,
484
- controllerGeneration: binding.controllerGeneration,
485
- targets: await sessionClient.closeTarget(targetId),
486
- }),
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
+ },
487
1493
  );
488
- return context.json(result);
1494
+ const response = ExternalAuthRunResponse.parse(result);
1495
+ observeAuthMutation(deps.observability, startedAtMs, response);
1496
+ return context.json(response);
489
1497
  },
490
1498
  );
491
1499
 
492
- app.get(
493
- "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/observation",
1500
+ app.post(
1501
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/external-auth/interactive",
494
1502
  async (context) => {
495
1503
  const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
496
1504
  context,
497
- "sessions:read",
1505
+ "sessions:control",
498
1506
  );
499
- const targetId = requireOpaqueParam(context, "targetId");
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);
500
1514
  const result = await withActiveBrowserController(
501
1515
  context,
502
1516
  grant,
503
1517
  workspaceId,
504
1518
  browserSessionId,
505
- "session.read",
506
- "browser.read",
507
- async ({ sessionClient }) => await sessionClient.observe(targetId),
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
+ },
508
1561
  );
509
- return context.json(result);
1562
+ return context.json(ExternalAuthInteractiveResponse.parse(result));
510
1563
  },
511
1564
  );
512
1565
 
513
1566
  app.post(
514
- "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/actions",
1567
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/auth-runs/:authRunId/verify",
515
1568
  async (context) => {
516
1569
  const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
517
1570
  context,
518
1571
  "sessions:control",
519
1572
  );
520
- const request = await parseJsonBody(context, BrowserActionRequest);
1573
+ const authRunId = requireUuidParam(context, "authRunId");
1574
+ const request = await parseJsonBody(context, VerifyAuthRunRequest);
1575
+ const startedAtMs = performance.now();
521
1576
  const result = await withActiveBrowserController(
522
1577
  context,
523
1578
  grant,
@@ -525,22 +1580,29 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
525
1580
  browserSessionId,
526
1581
  "session.control",
527
1582
  "browser.control",
528
- async ({ sessionClient, binding }) =>
529
- await sessionClient.action(
530
- BrowserActionCommand.parse({
531
- protocolVersion: BROWSER_CONTROL_PROTOCOL_VERSION,
532
- operationId: request.operationId,
533
- browserSessionId,
534
- controllerGeneration: binding.controllerGeneration,
535
- targetId: request.targetId,
536
- expectedTargetGeneration: request.expectedTargetGeneration,
537
- expectedDocumentGeneration: request.expectedDocumentGeneration,
538
- expectedFrameId: request.expectedFrameId,
539
- actor: interactionActorForGrant(grant),
540
- action: request.action,
541
- }),
542
- ),
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
+ },
543
1604
  );
1605
+ observeAuthMutation(deps.observability, startedAtMs, result);
544
1606
  return context.json(result);
545
1607
  },
546
1608
  );
@@ -602,6 +1664,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
602
1664
  context,
603
1665
  "stream:view",
604
1666
  );
1667
+ const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
605
1668
  const request = await parseJsonBody(context, BrowserSessionAttachmentRequest);
606
1669
  const result = await withActiveBrowserController(
607
1670
  context,
@@ -611,7 +1674,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
611
1674
  "session.viewer.read",
612
1675
  "browser.attach",
613
1676
  async ({ client, sessionClient, record, binding, placement }) => {
614
- await sessionClient.observe(request.targetId);
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
+ }
615
1682
  const grantId = randomUUID();
616
1683
  const expiresAt = new Date(Date.now() + request.expiresInSeconds * 1_000).toISOString();
617
1684
  const rootSecret = browserAuthorityRoot(deps);
@@ -639,7 +1706,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
639
1706
  browserSessionId,
640
1707
  controllerGeneration: binding.controllerGeneration,
641
1708
  };
642
- await client.createViewGrant(reference, { grantId, token, expiresAt });
1709
+ await client.createViewGrant(reference, {
1710
+ grantId,
1711
+ token,
1712
+ expiresAt,
1713
+ });
643
1714
  const relayed = await client.openRelayedFrameStream({
644
1715
  reference,
645
1716
  targetId: request.targetId,
@@ -672,7 +1743,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
672
1743
  })()
673
1744
  : {
674
1745
  kind: "direct_websocket" as const,
675
- url: await client.frameStreamUrl(reference, request.targetId),
1746
+ url: await client.frameStreamUrl(reference, request.targetId, request.stream),
676
1747
  protocols: [
677
1748
  BROWSER_CONTROL_WEBSOCKET_PROTOCOL,
678
1749
  `${BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX}${token}`,
@@ -714,6 +1785,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
714
1785
  alive: true,
715
1786
  });
716
1787
  },
1788
+ false,
717
1789
  );
718
1790
  return context.json(result);
719
1791
  },
@@ -726,6 +1798,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
726
1798
  const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
727
1799
  const browserSessionId = requireUuidParam(context, "browserSessionId");
728
1800
  const request = await parseJsonBody(context, PublishBrowserRevisionRequest);
1801
+ const startedAtMs = performance.now();
729
1802
  try {
730
1803
  const record = await getBrowserSessionControlRecord(deps.db, {
731
1804
  accountId: grant.accountId,
@@ -745,7 +1818,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
745
1818
  };
746
1819
  const prepared = await prepareBrowserRevisionPublication(deps.db, publicationInput);
747
1820
  if (prepared.kind === "completed") {
748
- return context.json(PublishBrowserRevisionResponse.parse(prepared.response), 200);
1821
+ const parsed = PublishBrowserRevisionResponse.parse(prepared.response);
1822
+ observeBrowserRevisionPublication(deps.observability, startedAtMs, parsed);
1823
+ return context.json(parsed, 200);
749
1824
  }
750
1825
  const objectStorage = deps.objectStorage;
751
1826
  if (!objectStorage) {
@@ -785,15 +1860,20 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
785
1860
  objectKey,
786
1861
  });
787
1862
  try {
788
- const signed = await objectStorage.createPutUrl({
789
- key: objectKey,
790
- contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
791
- });
792
1863
  const dispatched = await dispatchBrowserRevisionPublication(deps.db, {
793
1864
  ...publicationInput,
794
1865
  controllerGeneration: binding.controllerGeneration,
1866
+ stateUpload: {
1867
+ objectKey,
1868
+ cleanupAfter: new Date(Date.now() + BROWSER_STATE_UPLOAD_CLEANUP_GRACE_MS),
1869
+ },
795
1870
  });
796
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
+ });
797
1877
 
798
1878
  let receipt;
799
1879
  try {
@@ -863,6 +1943,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
863
1943
  },
864
1944
  );
865
1945
  const parsed = PublishBrowserRevisionResponse.parse(response);
1946
+ observeBrowserRevisionPublication(deps.observability, startedAtMs, parsed);
866
1947
  return context.json(parsed, parsed.replayed ? 200 : 201);
867
1948
  } catch (error) {
868
1949
  throw browserRouteError(error);
@@ -877,6 +1958,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
877
1958
  const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
878
1959
  const browserSessionId = requireUuidParam(context, "browserSessionId");
879
1960
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
1961
+ const startedAtMs = performance.now();
880
1962
  const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
881
1963
  try {
882
1964
  const before = await getBrowserSessionControlRecord(deps.db, {
@@ -885,6 +1967,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
885
1967
  browserSessionId,
886
1968
  });
887
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
+ }
888
1975
  browserAuthorityRoot(deps);
889
1976
  if (!deps.objectStorage) {
890
1977
  throw new HTTPException(503, {
@@ -906,21 +1993,22 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
906
1993
  workspaceId,
907
1994
  browserSessionId,
908
1995
  );
909
- return context.json(
910
- BrowserSessionMutationResponse.parse({
911
- session: (
912
- await getBrowserSessionControlRecord(deps.db, {
913
- accountId: grant.accountId,
914
- workspaceId,
915
- browserSessionId,
916
- })
917
- ).session,
918
- operation: prepared.operation,
919
- }),
920
- 200,
921
- );
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);
922
2008
  }
923
- return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
2009
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2010
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2011
+ return context.json(parsed, 200);
924
2012
  }
925
2013
 
926
2014
  const record = await getBrowserSessionControlRecord(deps.db, {
@@ -958,34 +2046,52 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
958
2046
  objectKey,
959
2047
  });
960
2048
  try {
961
- const signed = await objectStorage.createPutUrl({
962
- key: objectKey,
963
- contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
964
- });
965
2049
  await dispatchBrowserSessionOperation(deps.db, {
966
2050
  accountId: grant.accountId,
967
2051
  workspaceId,
968
2052
  operationId: request.operationId,
969
2053
  browserSessionId,
970
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),
971
2064
  });
972
2065
  const client = await provisionController(deps, grant, record, placement, origin);
973
2066
  let receipt: PlacementBrowserStateCaptureReceipt;
974
2067
  try {
975
- receipt = await client.captureState({
976
- browserSessionId,
977
- controllerGeneration: binding.controllerGeneration,
978
- operationId: request.operationId,
979
- objectKey,
980
- afterCapture: "stop",
981
- dataKey,
982
- aad,
983
- upload: {
984
- url: signed.url,
985
- requiredHeaders: signed.requiredHeaders,
986
- expiresAt: signed.expiresAt.toISOString(),
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,
987
2078
  },
988
- });
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
+ );
989
2095
  } catch (error) {
990
2096
  const failed = await settleBrowserSessionSuspensionCaptureFailure(
991
2097
  deps,
@@ -1043,7 +2149,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1043
2149
  grant,
1044
2150
  workspaceId,
1045
2151
  browserSessionId,
1046
- record.session.placement,
2152
+ record.controllerHostSandboxGroupId,
1047
2153
  );
1048
2154
  return BrowserSessionMutationResponse.parse({
1049
2155
  session: (
@@ -1062,7 +2168,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1062
2168
  }
1063
2169
  },
1064
2170
  );
1065
- return context.json(BrowserSessionMutationResponse.parse(response), 200);
2171
+ const parsed = BrowserSessionMutationResponse.parse(response);
2172
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2173
+ return context.json(parsed, 200);
1066
2174
  } catch (error) {
1067
2175
  throw browserRouteError(error);
1068
2176
  }
@@ -1076,6 +2184,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1076
2184
  const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
1077
2185
  const browserSessionId = requireUuidParam(context, "browserSessionId");
1078
2186
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2187
+ const startedAtMs = performance.now();
1079
2188
  const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
1080
2189
  let restore: RestorePlacementBrowserStateInput | null = null;
1081
2190
  try {
@@ -1098,7 +2207,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1098
2207
  browserSessionId,
1099
2208
  });
1100
2209
  }
1101
- browserAuthorityRoot(deps);
2210
+ const authority = browserAuthorityRoot(deps);
1102
2211
  const prepared = await prepareBrowserSessionResume(deps.db, {
1103
2212
  accountId: grant.accountId,
1104
2213
  workspaceId,
@@ -1107,7 +2216,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1107
2216
  actorSubjectId: grant.subjectId,
1108
2217
  });
1109
2218
  if (isTerminalOperation(prepared.operation.state)) {
1110
- return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
2219
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2220
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2221
+ return context.json(parsed, 200);
1111
2222
  }
1112
2223
 
1113
2224
  const privateCheckpoint = await getBrowserPrivateCheckpointAuthority(deps.db, {
@@ -1156,6 +2267,15 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1156
2267
  "browser.resume",
1157
2268
  context.req.raw.signal,
1158
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
+ });
1159
2279
  const interactionHeld = await ensureInteractionHolder(
1160
2280
  deps,
1161
2281
  grant,
@@ -1171,7 +2291,18 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1171
2291
  browserSessionId,
1172
2292
  request.operationId,
1173
2293
  placement.placementInstanceId,
1174
- );
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
+ });
1175
2306
  const controllerGeneration = requireOperationGeneration(record);
1176
2307
  const linkedComputer = await requireLinkedComputerBinding(
1177
2308
  deps,
@@ -1180,14 +2311,14 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1180
2311
  placement,
1181
2312
  );
1182
2313
  const adminToken = deriveBrowserControllerAdminToken({
1183
- rootSecret: browserAuthorityRoot(deps),
2314
+ rootSecret: authority,
1184
2315
  accountId: grant.accountId,
1185
2316
  workspaceId,
1186
2317
  placement: placement.placement,
1187
2318
  placementInstanceId: placement.placementInstanceId,
1188
2319
  });
1189
2320
  const tokens = deriveBrowserSessionControllerTokens({
1190
- rootSecret: browserAuthorityRoot(deps),
2321
+ rootSecret: authority,
1191
2322
  accountId: grant.accountId,
1192
2323
  workspaceId,
1193
2324
  browserSessionId,
@@ -1202,16 +2333,30 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1202
2333
  ...(origin ? { allowedOrigins: [origin] } : {}),
1203
2334
  });
1204
2335
  try {
1205
- await client.createSession({
1206
- browserSessionId,
1207
- controllerGeneration,
1208
- tokenGeneration: record.tokenGeneration,
1209
- ...tokens,
1210
- headed: !prepared.session.headless,
1211
- transport: placement.transport,
1212
- ...(linkedComputer ? { linkedComputer } : {}),
1213
- restore: restore!,
1214
- });
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
+ );
1215
2360
  } catch (error) {
1216
2361
  if (!isDefiniteBrowserControllerFailure(error)) throw error;
1217
2362
  try {
@@ -1243,7 +2388,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1243
2388
  grant,
1244
2389
  workspaceId,
1245
2390
  browserSessionId,
1246
- prepared.session.placement,
2391
+ placement.controllerHostSandboxGroupId,
1247
2392
  ).catch(() => undefined);
1248
2393
  }
1249
2394
  return failed;
@@ -1262,7 +2407,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1262
2407
  });
1263
2408
  },
1264
2409
  );
1265
- return context.json(BrowserSessionMutationResponse.parse(response), 200);
2410
+ const parsed = BrowserSessionMutationResponse.parse(response);
2411
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2412
+ return context.json(parsed, 200);
1266
2413
  } catch (error) {
1267
2414
  throw browserRouteError(error);
1268
2415
  } finally {
@@ -1279,6 +2426,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1279
2426
  const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
1280
2427
  const browserSessionId = requireUuidParam(context, "browserSessionId");
1281
2428
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2429
+ const startedAtMs = performance.now();
1282
2430
  const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
1283
2431
  try {
1284
2432
  const before = await getBrowserSessionControlRecord(deps.db, {
@@ -1302,10 +2450,12 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1302
2450
  grant,
1303
2451
  workspaceId,
1304
2452
  browserSessionId,
1305
- before.session.placement,
2453
+ before.controllerHostSandboxGroupId,
1306
2454
  ).catch(() => undefined);
1307
2455
  }
1308
- return context.json(BrowserSessionMutationResponse.parse(prepared), 200);
2456
+ const parsed = BrowserSessionMutationResponse.parse(prepared);
2457
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2458
+ return context.json(parsed, 200);
1309
2459
  }
1310
2460
 
1311
2461
  const record = await getBrowserSessionControlRecord(deps.db, {
@@ -1328,9 +2478,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1328
2478
  grant,
1329
2479
  workspaceId,
1330
2480
  browserSessionId,
1331
- record.session.placement,
2481
+ record.controllerHostSandboxGroupId,
1332
2482
  ).catch(() => undefined);
1333
- return context.json(BrowserSessionMutationResponse.parse(completed), 200);
2483
+ const parsed = BrowserSessionMutationResponse.parse(completed);
2484
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2485
+ return context.json(parsed, 200);
1334
2486
  }
1335
2487
  const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
1336
2488
  const response = await withBrowserPlacement(
@@ -1350,12 +2502,25 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1350
2502
  });
1351
2503
  const client = await provisionController(deps, grant, record, placement, origin);
1352
2504
  try {
1353
- await client.endSession(
2505
+ await withInteractionHolderHeartbeat(
2506
+ deps,
1354
2507
  {
1355
- browserSessionId,
2508
+ grant,
2509
+ workspaceId,
2510
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
2511
+ holderId: interactionHolderId(browserSessionId),
2512
+ operationId: request.operationId,
2513
+ resourceId: browserSessionId,
1356
2514
  controllerGeneration: binding.controllerGeneration,
1357
2515
  },
1358
- { removeState: true },
2516
+ async () =>
2517
+ await client.endSession(
2518
+ {
2519
+ browserSessionId,
2520
+ controllerGeneration: binding.controllerGeneration,
2521
+ },
2522
+ { removeState: true },
2523
+ ),
1359
2524
  );
1360
2525
  } catch (error) {
1361
2526
  // Physical absence already satisfies an exact end. A stale live
@@ -1376,12 +2541,14 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1376
2541
  grant,
1377
2542
  workspaceId,
1378
2543
  browserSessionId,
1379
- record.session.placement,
2544
+ placement.controllerHostSandboxGroupId,
1380
2545
  ).catch(() => undefined);
1381
2546
  return completed;
1382
2547
  },
1383
2548
  );
1384
- return context.json(BrowserSessionMutationResponse.parse(response), 200);
2549
+ const parsed = BrowserSessionMutationResponse.parse(response);
2550
+ observeLifecycleResult(deps.observability, startedAtMs, parsed);
2551
+ return context.json(parsed, 200);
1385
2552
  } catch (error) {
1386
2553
  throw browserRouteError(error);
1387
2554
  }
@@ -1442,6 +2609,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1442
2609
  waitSignal.throwIfAborted();
1443
2610
  return await callback({
1444
2611
  placement: expectedPlacement,
2612
+ controllerHostSandboxGroupId: null,
1445
2613
  placementInstanceId: device.connectionGeneration,
1446
2614
  session: built.session as unknown as BrowserControlPlacementSession,
1447
2615
  lease: null,
@@ -1454,7 +2622,14 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1454
2622
  },
1455
2623
  });
1456
2624
  }
1457
- return await withChannelA(
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(
1458
2633
  channelServices,
1459
2634
  {
1460
2635
  accountId: grant.accountId,
@@ -1463,6 +2638,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1463
2638
  subjectId: grant.subjectId,
1464
2639
  waitSignal,
1465
2640
  operation,
2641
+ retryControllerTransport: operation === "browser.read" || operation === "browser.action",
1466
2642
  },
1467
2643
  async (handle) => {
1468
2644
  if (expectedPlacement?.kind === "sandbox_group") {
@@ -1475,6 +2651,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1475
2651
  assertPlacementInstance(expectedPlacementInstanceId, handle.lease.instanceId);
1476
2652
  return await callback({
1477
2653
  placement: expectedPlacement,
2654
+ controllerHostSandboxGroupId: expectedPlacement.sandboxGroupId,
1478
2655
  placementInstanceId: handle.lease.instanceId,
1479
2656
  session: handle.homeSession,
1480
2657
  lease: handle.lease,
@@ -1482,6 +2659,23 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1482
2659
  });
1483
2660
  }
1484
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
+
1485
2679
  const resolved = await handle.routingSession.prime();
1486
2680
  if (expectedPlacement?.kind === "connected_machine") {
1487
2681
  if (
@@ -1496,18 +2690,13 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1496
2690
  assertPlacementInstance(expectedPlacementInstanceId, placementInstanceId);
1497
2691
  return await callback({
1498
2692
  placement: expectedPlacement,
2693
+ controllerHostSandboxGroupId: null,
1499
2694
  placementInstanceId,
1500
2695
  session: resolved.session as unknown as BrowserControlPlacementSession,
1501
2696
  lease: null,
1502
2697
  transport: { kind: "managed" },
1503
2698
  });
1504
2699
  }
1505
- if (expectedPlacement) {
1506
- throw new BrowserControlUnsupportedError(
1507
- `browser placement ${expectedPlacement.kind} is not executable yet`,
1508
- );
1509
- }
1510
-
1511
2700
  if (resolved.sandboxId === null) {
1512
2701
  if (!handle.lease?.instanceId) {
1513
2702
  throw new BrowserSessionStateError("BrowserSession home placement is unavailable");
@@ -1523,6 +2712,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1523
2712
  kind: "sandbox_group",
1524
2713
  sandboxGroupId: sourceSession.sandboxGroupId,
1525
2714
  },
2715
+ controllerHostSandboxGroupId: sourceSession.sandboxGroupId,
1526
2716
  placementInstanceId: handle.lease.instanceId,
1527
2717
  session: handle.homeSession,
1528
2718
  lease: handle.lease,
@@ -1536,6 +2726,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1536
2726
  };
1537
2727
  return await callback({
1538
2728
  placement,
2729
+ controllerHostSandboxGroupId: null,
1539
2730
  placementInstanceId: resolved.providerInstanceId ?? resolved.sandboxId,
1540
2731
  session: resolved.session as unknown as BrowserControlPlacementSession,
1541
2732
  lease: null,
@@ -1566,6 +2757,110 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1566
2757
  };
1567
2758
  }
1568
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
+
1569
2864
  async function withActiveBrowserController<T>(
1570
2865
  context: Context,
1571
2866
  grant: AccessGrant,
@@ -1580,6 +2875,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1580
2875
  binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
1581
2876
  placement: BrowserPlacement;
1582
2877
  }) => Promise<T>,
2878
+ recoverMissing = true,
1583
2879
  ): Promise<T> {
1584
2880
  try {
1585
2881
  const record = await getBrowserSessionControlRecord(deps.db, {
@@ -1601,6 +2897,76 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1601
2897
  if (!admitted) {
1602
2898
  throw new BrowserSessionStateError("BrowserSession controller authority changed");
1603
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
+
1604
2970
  const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
1605
2971
  return await withBrowserPlacement(
1606
2972
  sourceSession,
@@ -1609,51 +2975,60 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1609
2975
  binding.placementInstanceId,
1610
2976
  channelOperation,
1611
2977
  context.req.raw.signal,
1612
- async (placement) => {
1613
- const client = await provisionController(
1614
- deps,
1615
- grant,
1616
- record,
1617
- placement,
1618
- requestOrigin(context, deps.settings.corsAllowOriginRegex),
1619
- );
1620
- const tokens = deriveBrowserSessionControllerTokens({
1621
- rootSecret: browserAuthorityRoot(deps),
1622
- accountId: grant.accountId,
1623
- workspaceId,
1624
- placement: record.session.placement,
1625
- placementInstanceId: placement.placementInstanceId,
1626
- browserSessionId,
1627
- controllerGeneration: binding.controllerGeneration,
1628
- tokenGeneration: record.tokenGeneration,
1629
- });
1630
- const result = await callback({
1631
- client,
1632
- sessionClient: client.sessionClient({
1633
- reference: {
1634
- browserSessionId,
1635
- controllerGeneration: binding.controllerGeneration,
1636
- },
1637
- ...tokens,
1638
- }),
1639
- record,
1640
- binding,
1641
- placement,
1642
- });
1643
- await touchBrowserSessionController(deps.db, {
1644
- accountId: grant.accountId,
1645
- workspaceId,
1646
- browserSessionId,
1647
- controllerGeneration: binding.controllerGeneration,
1648
- }).catch(() => false);
1649
- return result;
1650
- },
2978
+ async (placement) =>
2979
+ await run(
2980
+ await cacheBrowserControllerPlacement(grant, workspaceId, placement).catch(
2981
+ () => placement,
2982
+ ),
2983
+ ),
1651
2984
  );
1652
2985
  } catch (error) {
1653
2986
  throw browserRouteError(error);
1654
2987
  }
1655
2988
  }
1656
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
+
1657
3032
  async function finishSuspendedBrowserControllerCleanup(
1658
3033
  context: Context,
1659
3034
  grant: AccessGrant,
@@ -1701,7 +3076,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
1701
3076
  grant,
1702
3077
  workspaceId,
1703
3078
  browserSessionId,
1704
- record.session.placement,
3079
+ record.controllerHostSandboxGroupId,
1705
3080
  );
1706
3081
  }
1707
3082
  }
@@ -1713,6 +3088,13 @@ function browserCreateInput(
1713
3088
  placement: InteractionPlacement,
1714
3089
  ) {
1715
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
+ }
1716
3098
  return {
1717
3099
  accountId: grant.accountId,
1718
3100
  workspaceId,
@@ -1722,18 +3104,89 @@ function browserCreateInput(
1722
3104
  name: request.name ?? "Browser",
1723
3105
  initialUrl: request.initialUrl ?? null,
1724
3106
  placement,
1725
- driverId: BROWSER_DRIVER_ID,
1726
- engine: attached ? ("chrome" as const) : ("chromium" as const),
3107
+ driverId:
3108
+ engine === "lightpanda"
3109
+ ? LIGHTPANDA_DRIVER_ID
3110
+ : external
3111
+ ? EXTERNAL_BROWSER_DRIVER_ID
3112
+ : BROWSER_DRIVER_ID,
3113
+ engine,
1727
3114
  headless: attached ? false : request.headless,
1728
3115
  identityId: request.identityId ?? null,
1729
3116
  baseRevisionId: request.baseRevisionId ?? null,
3117
+ networkRouteId: request.networkRouteId ?? null,
1730
3118
  linkedComputerSessionId: request.linkedComputerSessionId ?? null,
1731
3119
  resolveDefaultRevision:
1732
- !attached && request.identityId !== undefined && request.baseRevisionId === undefined,
1733
- ...(attached ? { capabilities: ATTACHED_BROWSER_SESSION_CAPABILITIES } : {}),
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",
1734
3142
  };
1735
3143
  }
1736
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
+
1737
3190
  function assertCreateReplay(
1738
3191
  request: CreateBrowserSessionRequestValue,
1739
3192
  session: BrowserSessionValue,
@@ -1743,11 +3196,27 @@ function assertCreateReplay(
1743
3196
  "BrowserSession create operation is bound to another placement",
1744
3197
  );
1745
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
+ }
1746
3210
  if ((request.identityId ?? null) !== session.identityId) {
1747
3211
  throw new BrowserSessionOperationConflictError(
1748
3212
  "BrowserSession create operation is bound to another identity",
1749
3213
  );
1750
3214
  }
3215
+ if ((request.networkRouteId ?? null) !== session.networkRouteId) {
3216
+ throw new BrowserSessionOperationConflictError(
3217
+ "BrowserSession create operation is bound to another network route",
3218
+ );
3219
+ }
1751
3220
  if (request.baseRevisionId !== undefined && request.baseRevisionId !== session.baseRevisionId) {
1752
3221
  throw new BrowserSessionOperationConflictError(
1753
3222
  "BrowserSession create operation is bound to another revision",
@@ -1926,6 +3395,7 @@ async function prepareBrowserArtifactRestore(
1926
3395
  try {
1927
3396
  const signed = await objectStorage.createGetUrl({
1928
3397
  key: artifact.objectKey,
3398
+ audience: signedUrlAudienceForPlacement(placement),
1929
3399
  });
1930
3400
  return {
1931
3401
  objectKey: artifact.objectKey,
@@ -1988,6 +3458,10 @@ function sameInteractionPlacement(
1988
3458
  }
1989
3459
  }
1990
3460
 
3461
+ function signedUrlAudienceForPlacement(placement: InteractionPlacement): "public" | "sandbox" {
3462
+ return placement.kind === "sandbox_group" ? "sandbox" : "public";
3463
+ }
3464
+
1991
3465
  async function ensureInteractionHolder(
1992
3466
  deps: ApiRouteDeps,
1993
3467
  grant: AccessGrant,
@@ -1996,14 +3470,14 @@ async function ensureInteractionHolder(
1996
3470
  placement: BrowserPlacement,
1997
3471
  waitSignal: AbortSignal,
1998
3472
  ): Promise<boolean> {
1999
- if (placement.placement.kind !== "sandbox_group") return false;
3473
+ if (!placement.controllerHostSandboxGroupId) return false;
2000
3474
  if (!placement.lease?.instanceId) {
2001
3475
  throw new BrowserSessionStateError("BrowserSession lease placement is unavailable");
2002
3476
  }
2003
3477
  const acquired = await acquireLease(deps.db, {
2004
3478
  accountId: grant.accountId,
2005
3479
  workspaceId: sourceSession.workspaceId,
2006
- sandboxGroupId: placement.placement.sandboxGroupId,
3480
+ sandboxGroupId: placement.controllerHostSandboxGroupId,
2007
3481
  kind: "interaction",
2008
3482
  holderId: interactionHolderId(browserSessionId),
2009
3483
  subjectId: sourceSession.id,
@@ -2028,7 +3502,7 @@ async function ensureInteractionHolder(
2028
3502
  grant,
2029
3503
  sourceSession.workspaceId,
2030
3504
  browserSessionId,
2031
- placement.placement,
3505
+ placement.controllerHostSandboxGroupId,
2032
3506
  ).catch(() => undefined);
2033
3507
  throw new BrowserSessionStateError("BrowserSession placement fence changed; retry");
2034
3508
  }
@@ -2075,7 +3549,7 @@ async function ensureDispatchedGeneration(
2075
3549
  });
2076
3550
  }
2077
3551
  if (record.operation?.state !== "dispatched") {
2078
- throw new BrowserSessionStateError("BrowserSession create operation is not dispatchable");
3552
+ throw new BrowserSessionStateError("BrowserSession operation is not dispatchable");
2079
3553
  }
2080
3554
  if (
2081
3555
  !record.session.controller ||
@@ -2119,6 +3593,24 @@ async function provisionController(
2119
3593
  ).client;
2120
3594
  }
2121
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
+
2122
3614
  function nativeBrowserControllerAuthority(
2123
3615
  workspaceId: string,
2124
3616
  placement: BrowserPlacement,
@@ -2146,13 +3638,13 @@ async function releaseInteractionHolder(
2146
3638
  grant: AccessGrant,
2147
3639
  workspaceId: string,
2148
3640
  browserSessionId: string,
2149
- placement: InteractionPlacement,
3641
+ controllerHostSandboxGroupId: string | null,
2150
3642
  ): Promise<void> {
2151
- if (placement.kind !== "sandbox_group") return;
3643
+ if (!controllerHostSandboxGroupId) return;
2152
3644
  await releaseLeaseHolder(deps.db, {
2153
3645
  accountId: grant.accountId,
2154
3646
  workspaceId,
2155
- sandboxGroupId: placement.sandboxGroupId,
3647
+ sandboxGroupId: controllerHostSandboxGroupId,
2156
3648
  kind: "interaction",
2157
3649
  holderId: interactionHolderId(browserSessionId),
2158
3650
  idleGraceMs: deps.settings.sandboxIdleGraceMs,
@@ -2233,6 +3725,16 @@ function isDefiniteBrowserControllerFailure(error: unknown): boolean {
2233
3725
  );
2234
3726
  }
2235
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
+
2236
3738
  async function settleBrowserSessionSuspensionCaptureFailure(
2237
3739
  deps: ApiRouteDeps,
2238
3740
  input: {
@@ -2451,6 +3953,353 @@ function isTerminalOperation(state: string): boolean {
2451
3953
  return state === "completed" || state === "failed" || state === "outcome_unknown";
2452
3954
  }
2453
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
+
2454
4303
  function interactionFailure(error: unknown) {
2455
4304
  if (error instanceof BrowserControlRequestError) return error.error;
2456
4305
  if (
@@ -2480,6 +4329,89 @@ function interactionFailure(error: unknown) {
2480
4329
  };
2481
4330
  }
2482
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
+
2483
4415
  function browserRouteError(error: unknown): HTTPException {
2484
4416
  if (error instanceof HTTPException) return error;
2485
4417
  if (error instanceof BrowserSessionNotFoundError) {
@@ -2491,11 +4423,18 @@ function browserRouteError(error: unknown): HTTPException {
2491
4423
  if (error instanceof BrowserIdentityNotFoundError) {
2492
4424
  return new HTTPException(404, { message: error.message, cause: error });
2493
4425
  }
4426
+ if (error instanceof InteractionResourceNotFoundError) {
4427
+ return new HTTPException(404, { message: error.message, cause: error });
4428
+ }
2494
4429
  if (
2495
4430
  error instanceof BrowserSessionOperationConflictError ||
2496
4431
  error instanceof BrowserSessionStateError ||
2497
4432
  error instanceof BrowserIdentityConflictError ||
2498
- error instanceof BrowserIdentityStateError
4433
+ error instanceof BrowserIdentityStateError ||
4434
+ error instanceof BrowserStateUploadStateError ||
4435
+ error instanceof InteractionResourceConflictError ||
4436
+ error instanceof InteractionResourceStateError ||
4437
+ error instanceof BrowserAuthCredentialError
2499
4438
  ) {
2500
4439
  return new HTTPException(409, { message: error.message, cause: error });
2501
4440
  }