@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.
- package/dist/app.d.ts +1 -0
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +17 -16
- package/src/app.ts +352 -49
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-JIKNR5YL.js.map +0 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
427
|
+
await withBrowserCreationController(
|
|
428
|
+
deps,
|
|
429
|
+
grant,
|
|
430
|
+
workspaceId,
|
|
431
|
+
placement,
|
|
323
432
|
adminToken,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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.
|
|
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 }) =>
|
|
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 }) =>
|
|
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.
|
|
467
|
-
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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.
|
|
493
|
-
"/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/
|
|
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:
|
|
1505
|
+
"sessions:control",
|
|
498
1506
|
);
|
|
499
|
-
|
|
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.
|
|
506
|
-
"browser.
|
|
507
|
-
async ({ sessionClient }) =>
|
|
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/
|
|
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
|
|
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
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2453
|
+
before.controllerHostSandboxGroupId,
|
|
1306
2454
|
).catch(() => undefined);
|
|
1307
2455
|
}
|
|
1308
|
-
|
|
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.
|
|
2481
|
+
record.controllerHostSandboxGroupId,
|
|
1332
2482
|
).catch(() => undefined);
|
|
1333
|
-
|
|
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
|
|
2505
|
+
await withInteractionHolderHeartbeat(
|
|
2506
|
+
deps,
|
|
1354
2507
|
{
|
|
1355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2544
|
+
placement.controllerHostSandboxGroupId,
|
|
1380
2545
|
).catch(() => undefined);
|
|
1381
2546
|
return completed;
|
|
1382
2547
|
},
|
|
1383
2548
|
);
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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.
|
|
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:
|
|
1726
|
-
|
|
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 &&
|
|
1733
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
3641
|
+
controllerHostSandboxGroupId: string | null,
|
|
2150
3642
|
): Promise<void> {
|
|
2151
|
-
if (
|
|
3643
|
+
if (!controllerHostSandboxGroupId) return;
|
|
2152
3644
|
await releaseLeaseHolder(deps.db, {
|
|
2153
3645
|
accountId: grant.accountId,
|
|
2154
3646
|
workspaceId,
|
|
2155
|
-
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
|
}
|