@opengeni/api-router 0.23.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/cors.d.ts +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +22 -1
- package/dist/integrations/provider-oauth.d.ts +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +8 -2
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/scheduled-task-view.d.ts +168 -0
- package/dist/mcp/server.d.ts +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +55 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +274 -37
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +2 -0
- package/src/http/cors.ts +3 -0
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +487 -27
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +32 -1
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +923 -145
- package/src/memory-slack-delivery.ts +209 -0
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +284 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +421 -245
- package/src/routes/skills.ts +174 -0
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/channel-a.ts +723 -116
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +29 -9
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolveFirstPartyDelegationSecret, resolveStreamTokenSecret } from "@opengeni/config";
|
|
3
|
+
import {
|
|
4
|
+
BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
|
|
5
|
+
COMPUTER_CONTROL_WEBSOCKET_PROTOCOL,
|
|
6
|
+
ComputerActionCommand,
|
|
7
|
+
ComputerActionRequest,
|
|
8
|
+
ComputerSessionAttachment,
|
|
9
|
+
ComputerSessionAttachmentRequest,
|
|
10
|
+
ComputerSessionHeartbeatResponse,
|
|
11
|
+
ComputerSessionLifecycleRequest,
|
|
12
|
+
ComputerSessionListResponse,
|
|
13
|
+
ComputerSessionMutationResponse,
|
|
14
|
+
ComputerTargetListResponse,
|
|
15
|
+
CreateComputerSessionRequest,
|
|
16
|
+
InteractionActor,
|
|
17
|
+
INTERACTION_PROTOCOL_VERSION,
|
|
18
|
+
type AccessGrant,
|
|
19
|
+
type ComputerSession as ComputerSessionValue,
|
|
20
|
+
type CreateComputerSessionRequest as CreateComputerSessionRequestValue,
|
|
21
|
+
type InteractionPlacement,
|
|
22
|
+
type Session,
|
|
23
|
+
type SessionAuthorizationOperation,
|
|
24
|
+
} from "@opengeni/contracts";
|
|
25
|
+
import {
|
|
26
|
+
acquireLease,
|
|
27
|
+
activateComputerSession,
|
|
28
|
+
completeComputerSessionEnd,
|
|
29
|
+
ComputerSessionNotFoundError,
|
|
30
|
+
ComputerSessionOperationConflictError,
|
|
31
|
+
ComputerSessionStateError,
|
|
32
|
+
dispatchComputerSessionOperation,
|
|
33
|
+
failComputerSessionOperation,
|
|
34
|
+
findComputerSessionControlRecordByOperation,
|
|
35
|
+
getComputerSessionControlRecord,
|
|
36
|
+
getSession,
|
|
37
|
+
listComputerSessions,
|
|
38
|
+
prepareComputerSessionCreate,
|
|
39
|
+
prepareComputerSessionEnd,
|
|
40
|
+
releaseLeaseHolder,
|
|
41
|
+
touchComputerSessionController,
|
|
42
|
+
type ComputerSessionControlRecord,
|
|
43
|
+
type LeaseSnapshot,
|
|
44
|
+
} from "@opengeni/db";
|
|
45
|
+
import {
|
|
46
|
+
requireAccessGrant,
|
|
47
|
+
requireSessionAuthorization,
|
|
48
|
+
SessionAuthorizationDeniedError,
|
|
49
|
+
SessionAuthorizationUnavailableError,
|
|
50
|
+
type ApiRouteDeps,
|
|
51
|
+
} from "@opengeni/core";
|
|
52
|
+
import {
|
|
53
|
+
BrowserControlProtocolError,
|
|
54
|
+
BrowserControlRequestError,
|
|
55
|
+
BrowserControlServerError,
|
|
56
|
+
BrowserControlServerUnsupportedError,
|
|
57
|
+
BrowserControlTransportError,
|
|
58
|
+
BrowserControlUnsupportedError,
|
|
59
|
+
buildStreamUrl,
|
|
60
|
+
mintStreamToken,
|
|
61
|
+
provisionBrowserControlClient,
|
|
62
|
+
type BrowserControlClient,
|
|
63
|
+
type BrowserControlPlacementSession,
|
|
64
|
+
} from "@opengeni/runtime/sandbox";
|
|
65
|
+
import type { Context, Hono } from "hono";
|
|
66
|
+
import { HTTPException } from "hono/http-exception";
|
|
67
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
68
|
+
import {
|
|
69
|
+
deriveBrowserControllerAdminToken,
|
|
70
|
+
deriveComputerSessionControllerTokens,
|
|
71
|
+
deriveComputerViewGrantToken,
|
|
72
|
+
} from "../browser-controller-authority";
|
|
73
|
+
import { allowedCorsOrigin } from "../http/cors";
|
|
74
|
+
import { withChannelA, type ChannelAOperation } from "../sandbox/channel-a";
|
|
75
|
+
|
|
76
|
+
type ComputerPlacement = {
|
|
77
|
+
placement: InteractionPlacement;
|
|
78
|
+
placementInstanceId: string;
|
|
79
|
+
session: BrowserControlPlacementSession;
|
|
80
|
+
lease: LeaseSnapshot | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Public ComputerSession resource surface. Physical app/window authority stays
|
|
84
|
+
* in the same placement controller used by BrowserSession; this route owns only
|
|
85
|
+
* durable authorization, placement fencing, lifecycle receipts, and routing. */
|
|
86
|
+
export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
87
|
+
const channelServices = {
|
|
88
|
+
db: deps.db,
|
|
89
|
+
settings: deps.settings,
|
|
90
|
+
bus: deps.bus,
|
|
91
|
+
observability: deps.observability,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
app.get("/v1/workspaces/:workspaceId/computer-sessions", async (context) => {
|
|
95
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
96
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
97
|
+
return context.json(
|
|
98
|
+
ComputerSessionListResponse.parse(
|
|
99
|
+
await listComputerSessions(deps.db, {
|
|
100
|
+
accountId: grant.accountId,
|
|
101
|
+
workspaceId,
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.get("/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId", async (context) => {
|
|
108
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
109
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
110
|
+
const computerSessionId = requireUuidParam(context, "computerSessionId");
|
|
111
|
+
try {
|
|
112
|
+
const record = await getComputerSessionControlRecord(deps.db, {
|
|
113
|
+
accountId: grant.accountId,
|
|
114
|
+
workspaceId,
|
|
115
|
+
computerSessionId,
|
|
116
|
+
});
|
|
117
|
+
await authorizeSourceSession(deps, grant, record.sourceSessionId, "session.read");
|
|
118
|
+
return context.json(record.session);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw computerRouteError(error);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.post("/v1/workspaces/:workspaceId/computer-sessions", async (context) => {
|
|
125
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
126
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
127
|
+
const request = await parseJsonBody(context, CreateComputerSessionRequest);
|
|
128
|
+
await authorizeSourceSession(deps, grant, request.sessionId, "session.control");
|
|
129
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
130
|
+
const authority = controllerAuthorityRoot(deps);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const existing = await findComputerSessionControlRecordByOperation(deps.db, {
|
|
134
|
+
accountId: grant.accountId,
|
|
135
|
+
workspaceId,
|
|
136
|
+
operationId: request.operationId,
|
|
137
|
+
});
|
|
138
|
+
if (existing && existing.sourceSessionId !== request.sessionId) {
|
|
139
|
+
throw new ComputerSessionOperationConflictError(
|
|
140
|
+
"ComputerSession create operation belongs to another source session",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (existing) assertCreateReplay(request, existing.session);
|
|
144
|
+
let prepared = existing
|
|
145
|
+
? await prepareComputerSessionCreate(
|
|
146
|
+
deps.db,
|
|
147
|
+
computerCreateInput(grant, workspaceId, request, existing.session.placement),
|
|
148
|
+
)
|
|
149
|
+
: null;
|
|
150
|
+
if (prepared && isTerminalOperation(prepared.operation.state)) {
|
|
151
|
+
return context.json(ComputerSessionMutationResponse.parse(prepared), 200);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, request.sessionId);
|
|
155
|
+
const response = await withComputerPlacement(
|
|
156
|
+
sourceSession,
|
|
157
|
+
grant,
|
|
158
|
+
existing?.session.placement ?? request.placement ?? null,
|
|
159
|
+
existing?.session.controller?.placementInstanceId ?? null,
|
|
160
|
+
"computer.create",
|
|
161
|
+
context.req.raw.signal,
|
|
162
|
+
async (placement) => {
|
|
163
|
+
if (!prepared) {
|
|
164
|
+
prepared = await prepareComputerSessionCreate(
|
|
165
|
+
deps.db,
|
|
166
|
+
computerCreateInput(grant, workspaceId, request, placement.placement),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (isTerminalOperation(prepared.operation.state)) return prepared;
|
|
170
|
+
|
|
171
|
+
const interactionHeld = await ensureInteractionHolder(
|
|
172
|
+
grant,
|
|
173
|
+
sourceSession,
|
|
174
|
+
prepared.session.id,
|
|
175
|
+
placement,
|
|
176
|
+
context.req.raw.signal,
|
|
177
|
+
);
|
|
178
|
+
const record = await ensureDispatchedGeneration(
|
|
179
|
+
grant,
|
|
180
|
+
workspaceId,
|
|
181
|
+
prepared.session.id,
|
|
182
|
+
request.operationId,
|
|
183
|
+
placement.placementInstanceId,
|
|
184
|
+
);
|
|
185
|
+
const controllerGeneration = requireOperationGeneration(record);
|
|
186
|
+
const adminToken = deriveBrowserControllerAdminToken({
|
|
187
|
+
rootSecret: authority,
|
|
188
|
+
accountId: grant.accountId,
|
|
189
|
+
workspaceId,
|
|
190
|
+
placement: placement.placement,
|
|
191
|
+
placementInstanceId: placement.placementInstanceId,
|
|
192
|
+
});
|
|
193
|
+
const tokens = deriveComputerSessionControllerTokens({
|
|
194
|
+
rootSecret: authority,
|
|
195
|
+
accountId: grant.accountId,
|
|
196
|
+
workspaceId,
|
|
197
|
+
computerSessionId: prepared.session.id,
|
|
198
|
+
placement: placement.placement,
|
|
199
|
+
placementInstanceId: placement.placementInstanceId,
|
|
200
|
+
controllerGeneration,
|
|
201
|
+
tokenGeneration: record.tokenGeneration,
|
|
202
|
+
});
|
|
203
|
+
let physical;
|
|
204
|
+
try {
|
|
205
|
+
const { client } = await provisionBrowserControlClient(placement.session, {
|
|
206
|
+
adminToken,
|
|
207
|
+
nativeAuthority: nativeControllerAuthority(workspaceId, placement),
|
|
208
|
+
...(origin ? { allowedOrigins: [origin] } : {}),
|
|
209
|
+
});
|
|
210
|
+
physical = await client.createComputerSession({
|
|
211
|
+
computerSessionId: prepared.session.id,
|
|
212
|
+
controllerGeneration,
|
|
213
|
+
tokenGeneration: record.tokenGeneration,
|
|
214
|
+
...tokens,
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (
|
|
218
|
+
error instanceof BrowserControlTransportError ||
|
|
219
|
+
(error instanceof BrowserControlRequestError && error.retryable) ||
|
|
220
|
+
isAbort(error)
|
|
221
|
+
) {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
const failed = await failComputerSessionOperation(deps.db, {
|
|
225
|
+
accountId: grant.accountId,
|
|
226
|
+
workspaceId,
|
|
227
|
+
operationId: request.operationId,
|
|
228
|
+
computerSessionId: prepared.session.id,
|
|
229
|
+
...(error instanceof BrowserControlProtocolError
|
|
230
|
+
? { state: "outcome_unknown" as const }
|
|
231
|
+
: {}),
|
|
232
|
+
error: interactionFailure(error),
|
|
233
|
+
});
|
|
234
|
+
if (interactionHeld && !(error instanceof BrowserControlProtocolError)) {
|
|
235
|
+
await releaseInteractionHolder(
|
|
236
|
+
grant,
|
|
237
|
+
workspaceId,
|
|
238
|
+
prepared.session.id,
|
|
239
|
+
placement.placement,
|
|
240
|
+
).catch(() => undefined);
|
|
241
|
+
}
|
|
242
|
+
return failed;
|
|
243
|
+
}
|
|
244
|
+
// These facts come from the physical adapter after its native helper
|
|
245
|
+
// and seat are live. The API never guesses a platform or display.
|
|
246
|
+
return await activateComputerSession(deps.db, {
|
|
247
|
+
accountId: grant.accountId,
|
|
248
|
+
workspaceId,
|
|
249
|
+
operationId: request.operationId,
|
|
250
|
+
computerSessionId: prepared.session.id,
|
|
251
|
+
controller: {
|
|
252
|
+
controllerId: "opengeni-browserd",
|
|
253
|
+
controllerGeneration,
|
|
254
|
+
placementInstanceId: placement.placementInstanceId,
|
|
255
|
+
},
|
|
256
|
+
platform: physical.platform,
|
|
257
|
+
adapter: physical.adapter,
|
|
258
|
+
seatId: physical.seatId,
|
|
259
|
+
displayId: physical.displayId,
|
|
260
|
+
capabilities: physical.capabilities,
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
const parsed = ComputerSessionMutationResponse.parse(response);
|
|
265
|
+
return context.json(
|
|
266
|
+
parsed,
|
|
267
|
+
parsed.operation.state === "completed" && !parsed.operation.replayed ? 201 : 200,
|
|
268
|
+
);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw computerRouteError(error);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
app.get(
|
|
275
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/targets",
|
|
276
|
+
async (context) => {
|
|
277
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
278
|
+
context,
|
|
279
|
+
"sessions:read",
|
|
280
|
+
);
|
|
281
|
+
const result = await withActiveComputerController(
|
|
282
|
+
context,
|
|
283
|
+
grant,
|
|
284
|
+
workspaceId,
|
|
285
|
+
computerSessionId,
|
|
286
|
+
"session.read",
|
|
287
|
+
"computer.read",
|
|
288
|
+
async ({ sessionClient, binding }) =>
|
|
289
|
+
ComputerTargetListResponse.parse({
|
|
290
|
+
computerSessionId,
|
|
291
|
+
controllerGeneration: binding.controllerGeneration,
|
|
292
|
+
targets: await sessionClient.listTargets(),
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
return context.json(result);
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
app.get(
|
|
300
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/targets/:targetId/observation",
|
|
301
|
+
async (context) => {
|
|
302
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
303
|
+
context,
|
|
304
|
+
"sessions:read",
|
|
305
|
+
);
|
|
306
|
+
const targetId = requireOpaqueParam(context, "targetId");
|
|
307
|
+
const result = await withActiveComputerController(
|
|
308
|
+
context,
|
|
309
|
+
grant,
|
|
310
|
+
workspaceId,
|
|
311
|
+
computerSessionId,
|
|
312
|
+
"session.read",
|
|
313
|
+
"computer.read",
|
|
314
|
+
async ({ sessionClient }) => await sessionClient.observe(targetId),
|
|
315
|
+
);
|
|
316
|
+
return context.json(result);
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
app.post(
|
|
321
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/actions",
|
|
322
|
+
async (context) => {
|
|
323
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
324
|
+
context,
|
|
325
|
+
"sessions:control",
|
|
326
|
+
);
|
|
327
|
+
const request = await parseJsonBody(context, ComputerActionRequest);
|
|
328
|
+
const result = await withActiveComputerController(
|
|
329
|
+
context,
|
|
330
|
+
grant,
|
|
331
|
+
workspaceId,
|
|
332
|
+
computerSessionId,
|
|
333
|
+
"session.control",
|
|
334
|
+
"computer.control",
|
|
335
|
+
async ({ sessionClient, binding }) =>
|
|
336
|
+
await sessionClient.action(
|
|
337
|
+
ComputerActionCommand.parse({
|
|
338
|
+
protocolVersion: INTERACTION_PROTOCOL_VERSION,
|
|
339
|
+
operationId: request.operationId,
|
|
340
|
+
computerSessionId,
|
|
341
|
+
controllerGeneration: binding.controllerGeneration,
|
|
342
|
+
targetId: request.targetId,
|
|
343
|
+
expectedTargetGeneration: request.expectedTargetGeneration,
|
|
344
|
+
expectedObservationId: request.expectedObservationId,
|
|
345
|
+
expectedFrameId: request.expectedFrameId,
|
|
346
|
+
actor: interactionActorForGrant(grant),
|
|
347
|
+
action: request.action,
|
|
348
|
+
}),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
return context.json(result);
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
app.get(
|
|
356
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/operations/:operationId",
|
|
357
|
+
async (context) => {
|
|
358
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
359
|
+
context,
|
|
360
|
+
"sessions:read",
|
|
361
|
+
);
|
|
362
|
+
const operationId = requireUuidParam(context, "operationId");
|
|
363
|
+
const result = await withActiveComputerController(
|
|
364
|
+
context,
|
|
365
|
+
grant,
|
|
366
|
+
workspaceId,
|
|
367
|
+
computerSessionId,
|
|
368
|
+
"session.read",
|
|
369
|
+
"computer.read",
|
|
370
|
+
async ({ sessionClient }) => await sessionClient.receipt(operationId),
|
|
371
|
+
);
|
|
372
|
+
return context.json(result);
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
app.post(
|
|
377
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/attachments",
|
|
378
|
+
async (context) => {
|
|
379
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(context, "stream:view");
|
|
380
|
+
const request = await parseJsonBody(context, ComputerSessionAttachmentRequest);
|
|
381
|
+
const result = await withActiveComputerController(
|
|
382
|
+
context,
|
|
383
|
+
grant,
|
|
384
|
+
workspaceId,
|
|
385
|
+
computerSessionId,
|
|
386
|
+
"session.viewer.read",
|
|
387
|
+
"computer.attach",
|
|
388
|
+
async ({ client, sessionClient, record, binding, placement }) => {
|
|
389
|
+
await sessionClient.observe(request.targetId);
|
|
390
|
+
const grantId = randomUUID();
|
|
391
|
+
const expiresAt = new Date(Date.now() + request.expiresInSeconds * 1_000).toISOString();
|
|
392
|
+
const token = deriveComputerViewGrantToken({
|
|
393
|
+
rootSecret: controllerAuthorityRoot(deps),
|
|
394
|
+
accountId: grant.accountId,
|
|
395
|
+
workspaceId,
|
|
396
|
+
placement: record.session.placement,
|
|
397
|
+
placementInstanceId: placement.placementInstanceId,
|
|
398
|
+
computerSessionId,
|
|
399
|
+
controllerGeneration: binding.controllerGeneration,
|
|
400
|
+
tokenGeneration: record.tokenGeneration,
|
|
401
|
+
grantId,
|
|
402
|
+
expiresAt,
|
|
403
|
+
});
|
|
404
|
+
const reference = {
|
|
405
|
+
computerSessionId,
|
|
406
|
+
controllerGeneration: binding.controllerGeneration,
|
|
407
|
+
};
|
|
408
|
+
await client.createComputerViewGrant(reference, { grantId, token, expiresAt });
|
|
409
|
+
const relaySecret = placement.session.openComputerFrames
|
|
410
|
+
? resolveStreamTokenSecret(deps.settings)
|
|
411
|
+
: null;
|
|
412
|
+
if (placement.session.openComputerFrames && !relaySecret) {
|
|
413
|
+
throw new BrowserControlUnsupportedError(
|
|
414
|
+
"computer frame relay authority is unavailable",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
const relayed = await client.openRelayedComputerFrameStream({
|
|
418
|
+
reference,
|
|
419
|
+
targetId: request.targetId,
|
|
420
|
+
viewToken: token,
|
|
421
|
+
expiresAt,
|
|
422
|
+
...(request.stream ? { stream: request.stream } : {}),
|
|
423
|
+
});
|
|
424
|
+
const stream = relayed
|
|
425
|
+
? await (async () => {
|
|
426
|
+
const relayToken = await mintStreamToken(relaySecret!, {
|
|
427
|
+
workspaceId,
|
|
428
|
+
sessionId: record.sourceSessionId,
|
|
429
|
+
viewerId: grantId,
|
|
430
|
+
leaseEpoch: record.tokenGeneration,
|
|
431
|
+
port: relayed.channel.port,
|
|
432
|
+
ttlSeconds: request.expiresInSeconds,
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
kind: "relay" as const,
|
|
436
|
+
url: buildStreamUrl(relayed.endpoint),
|
|
437
|
+
token: relayToken,
|
|
438
|
+
channel: {
|
|
439
|
+
channelId: relayed.channel.channelId,
|
|
440
|
+
workspaceId: relayed.channel.workspaceId,
|
|
441
|
+
agentId: relayed.channel.agentId,
|
|
442
|
+
kind: 4 as const,
|
|
443
|
+
port: relayed.channel.port,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
})()
|
|
447
|
+
: {
|
|
448
|
+
kind: "direct_websocket" as const,
|
|
449
|
+
url: await client.computerFrameStreamUrl(reference, request.targetId),
|
|
450
|
+
protocols: [
|
|
451
|
+
COMPUTER_CONTROL_WEBSOCKET_PROTOCOL,
|
|
452
|
+
`${BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX}${token}`,
|
|
453
|
+
],
|
|
454
|
+
};
|
|
455
|
+
return ComputerSessionAttachment.parse({
|
|
456
|
+
computerSessionId,
|
|
457
|
+
controllerGeneration: binding.controllerGeneration,
|
|
458
|
+
targetId: request.targetId,
|
|
459
|
+
stream,
|
|
460
|
+
expiresAt,
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
return context.json(result, 201);
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
app.post(
|
|
469
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/heartbeat",
|
|
470
|
+
async (context) => {
|
|
471
|
+
const { workspaceId, grant, computerSessionId } = await routePreamble(
|
|
472
|
+
context,
|
|
473
|
+
"sessions:read",
|
|
474
|
+
);
|
|
475
|
+
await parseEmptyJsonBody(context);
|
|
476
|
+
const result = await withActiveComputerController(
|
|
477
|
+
context,
|
|
478
|
+
grant,
|
|
479
|
+
workspaceId,
|
|
480
|
+
computerSessionId,
|
|
481
|
+
"session.read",
|
|
482
|
+
"computer.read",
|
|
483
|
+
async ({ sessionClient, binding }) => {
|
|
484
|
+
await sessionClient.heartbeat();
|
|
485
|
+
return ComputerSessionHeartbeatResponse.parse({
|
|
486
|
+
computerSessionId,
|
|
487
|
+
controllerGeneration: binding.controllerGeneration,
|
|
488
|
+
alive: true,
|
|
489
|
+
});
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
return context.json(result);
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
app.post(
|
|
497
|
+
"/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/end",
|
|
498
|
+
async (context) => {
|
|
499
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
500
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "sessions:control");
|
|
501
|
+
const computerSessionId = requireUuidParam(context, "computerSessionId");
|
|
502
|
+
const request = await parseJsonBody(context, ComputerSessionLifecycleRequest);
|
|
503
|
+
const origin = requestOrigin(context, deps.settings.corsAllowOriginRegex);
|
|
504
|
+
try {
|
|
505
|
+
const before = await getComputerSessionControlRecord(deps.db, {
|
|
506
|
+
accountId: grant.accountId,
|
|
507
|
+
workspaceId,
|
|
508
|
+
computerSessionId,
|
|
509
|
+
});
|
|
510
|
+
await authorizeSourceSession(deps, grant, before.sourceSessionId, "session.control");
|
|
511
|
+
if (before.session.controller) controllerAuthorityRoot(deps);
|
|
512
|
+
const prepared = await prepareComputerSessionEnd(deps.db, {
|
|
513
|
+
accountId: grant.accountId,
|
|
514
|
+
workspaceId,
|
|
515
|
+
computerSessionId,
|
|
516
|
+
operationId: request.operationId,
|
|
517
|
+
actorSubjectId: grant.subjectId,
|
|
518
|
+
});
|
|
519
|
+
if (isTerminalOperation(prepared.operation.state)) {
|
|
520
|
+
if (prepared.operation.state === "completed") {
|
|
521
|
+
await releaseInteractionHolder(
|
|
522
|
+
grant,
|
|
523
|
+
workspaceId,
|
|
524
|
+
computerSessionId,
|
|
525
|
+
before.session.placement,
|
|
526
|
+
).catch(() => undefined);
|
|
527
|
+
}
|
|
528
|
+
return context.json(ComputerSessionMutationResponse.parse(prepared), 200);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const record = await getComputerSessionControlRecord(deps.db, {
|
|
532
|
+
accountId: grant.accountId,
|
|
533
|
+
workspaceId,
|
|
534
|
+
computerSessionId,
|
|
535
|
+
operationId: request.operationId,
|
|
536
|
+
});
|
|
537
|
+
const binding = record.session.controller;
|
|
538
|
+
if (!binding) {
|
|
539
|
+
const completed = await completeComputerSessionEnd(deps.db, {
|
|
540
|
+
accountId: grant.accountId,
|
|
541
|
+
workspaceId,
|
|
542
|
+
operationId: request.operationId,
|
|
543
|
+
computerSessionId,
|
|
544
|
+
expectedControllerGeneration: null,
|
|
545
|
+
});
|
|
546
|
+
await releaseInteractionHolder(
|
|
547
|
+
grant,
|
|
548
|
+
workspaceId,
|
|
549
|
+
computerSessionId,
|
|
550
|
+
record.session.placement,
|
|
551
|
+
).catch(() => undefined);
|
|
552
|
+
return context.json(ComputerSessionMutationResponse.parse(completed), 200);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
556
|
+
const response = await withComputerPlacement(
|
|
557
|
+
sourceSession,
|
|
558
|
+
grant,
|
|
559
|
+
record.session.placement,
|
|
560
|
+
binding.placementInstanceId,
|
|
561
|
+
"computer.end",
|
|
562
|
+
context.req.raw.signal,
|
|
563
|
+
async (placement) => {
|
|
564
|
+
await dispatchComputerSessionOperation(deps.db, {
|
|
565
|
+
accountId: grant.accountId,
|
|
566
|
+
workspaceId,
|
|
567
|
+
operationId: request.operationId,
|
|
568
|
+
computerSessionId,
|
|
569
|
+
controllerGeneration: binding.controllerGeneration,
|
|
570
|
+
});
|
|
571
|
+
const client = await provisionController(grant, record, placement, origin);
|
|
572
|
+
try {
|
|
573
|
+
await client.endComputerSession(
|
|
574
|
+
{
|
|
575
|
+
computerSessionId,
|
|
576
|
+
controllerGeneration: binding.controllerGeneration,
|
|
577
|
+
},
|
|
578
|
+
{ removeState: true },
|
|
579
|
+
);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (!(error instanceof BrowserControlRequestError && error.status === 404)) {
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const completed = await completeComputerSessionEnd(deps.db, {
|
|
586
|
+
accountId: grant.accountId,
|
|
587
|
+
workspaceId,
|
|
588
|
+
operationId: request.operationId,
|
|
589
|
+
computerSessionId,
|
|
590
|
+
expectedControllerGeneration: binding.controllerGeneration,
|
|
591
|
+
});
|
|
592
|
+
await releaseInteractionHolder(
|
|
593
|
+
grant,
|
|
594
|
+
workspaceId,
|
|
595
|
+
computerSessionId,
|
|
596
|
+
record.session.placement,
|
|
597
|
+
).catch(() => undefined);
|
|
598
|
+
return completed;
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
return context.json(ComputerSessionMutationResponse.parse(response), 200);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
throw computerRouteError(error);
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
async function withComputerPlacement<T>(
|
|
609
|
+
sourceSession: Session,
|
|
610
|
+
grant: AccessGrant,
|
|
611
|
+
expectedPlacement: InteractionPlacement | null,
|
|
612
|
+
expectedPlacementInstanceId: string | null,
|
|
613
|
+
operation: ChannelAOperation,
|
|
614
|
+
waitSignal: AbortSignal,
|
|
615
|
+
callback: (placement: ComputerPlacement) => Promise<T>,
|
|
616
|
+
): Promise<T> {
|
|
617
|
+
if (
|
|
618
|
+
expectedPlacement?.kind === "attached_device" ||
|
|
619
|
+
expectedPlacement?.kind === "external_provider"
|
|
620
|
+
) {
|
|
621
|
+
throw new BrowserControlUnsupportedError(
|
|
622
|
+
`computer placement ${expectedPlacement.kind} is not executable`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return await withChannelA(
|
|
626
|
+
channelServices,
|
|
627
|
+
{
|
|
628
|
+
accountId: grant.accountId,
|
|
629
|
+
workspaceId: sourceSession.workspaceId,
|
|
630
|
+
session: sourceSession,
|
|
631
|
+
subjectId: grant.subjectId,
|
|
632
|
+
waitSignal,
|
|
633
|
+
operation,
|
|
634
|
+
},
|
|
635
|
+
async (handle) => {
|
|
636
|
+
if (expectedPlacement?.kind === "sandbox_group") {
|
|
637
|
+
if (
|
|
638
|
+
sourceSession.sandboxGroupId !== expectedPlacement.sandboxGroupId ||
|
|
639
|
+
!handle.lease?.instanceId
|
|
640
|
+
) {
|
|
641
|
+
throw new ComputerSessionStateError("ComputerSession home placement is unavailable");
|
|
642
|
+
}
|
|
643
|
+
assertPlacementInstance(expectedPlacementInstanceId, handle.lease.instanceId);
|
|
644
|
+
return await callback({
|
|
645
|
+
placement: expectedPlacement,
|
|
646
|
+
placementInstanceId: handle.lease.instanceId,
|
|
647
|
+
session: handle.homeSession,
|
|
648
|
+
lease: handle.lease,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const resolved = await handle.routingSession.prime();
|
|
653
|
+
if (expectedPlacement?.kind === "connected_machine") {
|
|
654
|
+
if (
|
|
655
|
+
resolved.kind !== "selfhosted" ||
|
|
656
|
+
resolved.sandboxId !== expectedPlacement.sandboxId
|
|
657
|
+
) {
|
|
658
|
+
throw new ComputerSessionStateError(
|
|
659
|
+
"ComputerSession Connected Machine is not the active placement",
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const placementInstanceId = resolved.providerInstanceId ?? expectedPlacement.sandboxId;
|
|
663
|
+
assertPlacementInstance(expectedPlacementInstanceId, placementInstanceId);
|
|
664
|
+
return await callback({
|
|
665
|
+
placement: expectedPlacement,
|
|
666
|
+
placementInstanceId,
|
|
667
|
+
session: resolved.session as unknown as BrowserControlPlacementSession,
|
|
668
|
+
lease: null,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (resolved.sandboxId === null) {
|
|
672
|
+
if (!handle.lease?.instanceId) {
|
|
673
|
+
throw new ComputerSessionStateError("ComputerSession home placement is unavailable");
|
|
674
|
+
}
|
|
675
|
+
if (
|
|
676
|
+
resolved.providerInstanceId &&
|
|
677
|
+
resolved.providerInstanceId !== handle.lease.instanceId
|
|
678
|
+
) {
|
|
679
|
+
throw new ComputerSessionStateError("ComputerSession home placement fence changed");
|
|
680
|
+
}
|
|
681
|
+
return await callback({
|
|
682
|
+
placement: {
|
|
683
|
+
kind: "sandbox_group",
|
|
684
|
+
sandboxGroupId: sourceSession.sandboxGroupId,
|
|
685
|
+
},
|
|
686
|
+
placementInstanceId: handle.lease.instanceId,
|
|
687
|
+
session: handle.homeSession,
|
|
688
|
+
lease: handle.lease,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (resolved.kind === "selfhosted") {
|
|
692
|
+
return await callback({
|
|
693
|
+
placement: { kind: "connected_machine", sandboxId: resolved.sandboxId },
|
|
694
|
+
placementInstanceId: resolved.providerInstanceId ?? resolved.sandboxId,
|
|
695
|
+
session: resolved.session as unknown as BrowserControlPlacementSession,
|
|
696
|
+
lease: null,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
throw new BrowserControlUnsupportedError(
|
|
700
|
+
"computer creation on a non-home provider sandbox is not supported",
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function withActiveComputerController<T>(
|
|
707
|
+
context: Context,
|
|
708
|
+
grant: AccessGrant,
|
|
709
|
+
workspaceId: string,
|
|
710
|
+
computerSessionId: string,
|
|
711
|
+
authorizationOperation: SessionAuthorizationOperation,
|
|
712
|
+
channelOperation: ChannelAOperation,
|
|
713
|
+
callback: (input: {
|
|
714
|
+
client: BrowserControlClient;
|
|
715
|
+
sessionClient: ReturnType<BrowserControlClient["computerSessionClient"]>;
|
|
716
|
+
record: ComputerSessionControlRecord;
|
|
717
|
+
binding: NonNullable<ComputerSessionControlRecord["session"]["controller"]>;
|
|
718
|
+
placement: ComputerPlacement;
|
|
719
|
+
}) => Promise<T>,
|
|
720
|
+
): Promise<T> {
|
|
721
|
+
try {
|
|
722
|
+
const record = await getComputerSessionControlRecord(deps.db, {
|
|
723
|
+
accountId: grant.accountId,
|
|
724
|
+
workspaceId,
|
|
725
|
+
computerSessionId,
|
|
726
|
+
});
|
|
727
|
+
await authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation);
|
|
728
|
+
if (record.session.lifecycle !== "active" || !record.session.controller) {
|
|
729
|
+
throw new ComputerSessionStateError("ComputerSession is not active");
|
|
730
|
+
}
|
|
731
|
+
const binding = record.session.controller;
|
|
732
|
+
const admitted = await touchComputerSessionController(deps.db, {
|
|
733
|
+
accountId: grant.accountId,
|
|
734
|
+
workspaceId,
|
|
735
|
+
computerSessionId,
|
|
736
|
+
controllerGeneration: binding.controllerGeneration,
|
|
737
|
+
});
|
|
738
|
+
if (!admitted) {
|
|
739
|
+
throw new ComputerSessionStateError("ComputerSession controller authority changed");
|
|
740
|
+
}
|
|
741
|
+
const sourceSession = await requireSourceSession(deps, workspaceId, record.sourceSessionId);
|
|
742
|
+
return await withComputerPlacement(
|
|
743
|
+
sourceSession,
|
|
744
|
+
grant,
|
|
745
|
+
record.session.placement,
|
|
746
|
+
binding.placementInstanceId,
|
|
747
|
+
channelOperation,
|
|
748
|
+
context.req.raw.signal,
|
|
749
|
+
async (placement) => {
|
|
750
|
+
const client = await provisionController(
|
|
751
|
+
grant,
|
|
752
|
+
record,
|
|
753
|
+
placement,
|
|
754
|
+
requestOrigin(context, deps.settings.corsAllowOriginRegex),
|
|
755
|
+
);
|
|
756
|
+
const tokens = deriveComputerSessionControllerTokens({
|
|
757
|
+
rootSecret: controllerAuthorityRoot(deps),
|
|
758
|
+
accountId: grant.accountId,
|
|
759
|
+
workspaceId,
|
|
760
|
+
placement: record.session.placement,
|
|
761
|
+
placementInstanceId: placement.placementInstanceId,
|
|
762
|
+
computerSessionId,
|
|
763
|
+
controllerGeneration: binding.controllerGeneration,
|
|
764
|
+
tokenGeneration: record.tokenGeneration,
|
|
765
|
+
});
|
|
766
|
+
const result = await callback({
|
|
767
|
+
client,
|
|
768
|
+
sessionClient: client.computerSessionClient({
|
|
769
|
+
reference: {
|
|
770
|
+
computerSessionId,
|
|
771
|
+
controllerGeneration: binding.controllerGeneration,
|
|
772
|
+
},
|
|
773
|
+
...tokens,
|
|
774
|
+
}),
|
|
775
|
+
record,
|
|
776
|
+
binding,
|
|
777
|
+
placement,
|
|
778
|
+
});
|
|
779
|
+
await touchComputerSessionController(deps.db, {
|
|
780
|
+
accountId: grant.accountId,
|
|
781
|
+
workspaceId,
|
|
782
|
+
computerSessionId,
|
|
783
|
+
controllerGeneration: binding.controllerGeneration,
|
|
784
|
+
}).catch(() => false);
|
|
785
|
+
return result;
|
|
786
|
+
},
|
|
787
|
+
);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
throw computerRouteError(error);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function ensureInteractionHolder(
|
|
794
|
+
grant: AccessGrant,
|
|
795
|
+
sourceSession: Session,
|
|
796
|
+
computerSessionId: string,
|
|
797
|
+
placement: ComputerPlacement,
|
|
798
|
+
waitSignal: AbortSignal,
|
|
799
|
+
): Promise<boolean> {
|
|
800
|
+
if (placement.placement.kind !== "sandbox_group") return false;
|
|
801
|
+
if (!placement.lease?.instanceId) {
|
|
802
|
+
throw new ComputerSessionStateError("ComputerSession lease placement is unavailable");
|
|
803
|
+
}
|
|
804
|
+
const acquired = await acquireLease(deps.db, {
|
|
805
|
+
accountId: grant.accountId,
|
|
806
|
+
workspaceId: sourceSession.workspaceId,
|
|
807
|
+
sandboxGroupId: placement.placement.sandboxGroupId,
|
|
808
|
+
kind: "interaction",
|
|
809
|
+
holderId: interactionHolderId(computerSessionId),
|
|
810
|
+
subjectId: sourceSession.id,
|
|
811
|
+
backend: placement.lease.backend,
|
|
812
|
+
os: placement.lease.os,
|
|
813
|
+
image: placement.lease.image,
|
|
814
|
+
rigVersionId: placement.lease.rigVersionId,
|
|
815
|
+
leaseTtlMs: deps.settings.sandboxLeaseTtlMs,
|
|
816
|
+
expectedEpoch: placement.lease.leaseEpoch,
|
|
817
|
+
waitSignal,
|
|
818
|
+
});
|
|
819
|
+
if (acquired.role === "blocked" || acquired.role === "fenced") {
|
|
820
|
+
throw new ComputerSessionStateError("ComputerSession placement is transitioning; retry");
|
|
821
|
+
}
|
|
822
|
+
if (
|
|
823
|
+
acquired.role === "spawner" ||
|
|
824
|
+
acquired.lease.leaseEpoch !== placement.lease.leaseEpoch ||
|
|
825
|
+
acquired.lease.instanceId !== placement.placementInstanceId
|
|
826
|
+
) {
|
|
827
|
+
await releaseInteractionHolder(
|
|
828
|
+
grant,
|
|
829
|
+
sourceSession.workspaceId,
|
|
830
|
+
computerSessionId,
|
|
831
|
+
placement.placement,
|
|
832
|
+
).catch(() => undefined);
|
|
833
|
+
throw new ComputerSessionStateError("ComputerSession placement fence changed; retry");
|
|
834
|
+
}
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function ensureDispatchedGeneration(
|
|
839
|
+
grant: AccessGrant,
|
|
840
|
+
workspaceId: string,
|
|
841
|
+
computerSessionId: string,
|
|
842
|
+
operationId: string,
|
|
843
|
+
placementInstanceId: string,
|
|
844
|
+
): Promise<ComputerSessionControlRecord> {
|
|
845
|
+
let record = await getComputerSessionControlRecord(deps.db, {
|
|
846
|
+
accountId: grant.accountId,
|
|
847
|
+
workspaceId,
|
|
848
|
+
computerSessionId,
|
|
849
|
+
operationId,
|
|
850
|
+
});
|
|
851
|
+
if (record.operation?.state === "prepared") {
|
|
852
|
+
const controllerGeneration = randomUUID();
|
|
853
|
+
try {
|
|
854
|
+
await dispatchComputerSessionOperation(deps.db, {
|
|
855
|
+
accountId: grant.accountId,
|
|
856
|
+
workspaceId,
|
|
857
|
+
operationId,
|
|
858
|
+
computerSessionId,
|
|
859
|
+
controllerGeneration,
|
|
860
|
+
controller: {
|
|
861
|
+
controllerId: "opengeni-browserd",
|
|
862
|
+
controllerGeneration,
|
|
863
|
+
placementInstanceId,
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (!(error instanceof ComputerSessionOperationConflictError)) throw error;
|
|
868
|
+
}
|
|
869
|
+
record = await getComputerSessionControlRecord(deps.db, {
|
|
870
|
+
accountId: grant.accountId,
|
|
871
|
+
workspaceId,
|
|
872
|
+
computerSessionId,
|
|
873
|
+
operationId,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
if (record.operation?.state !== "dispatched") {
|
|
877
|
+
throw new ComputerSessionStateError("ComputerSession create operation is not dispatchable");
|
|
878
|
+
}
|
|
879
|
+
if (
|
|
880
|
+
!record.session.controller ||
|
|
881
|
+
record.session.controller.controllerGeneration !== record.operation.controllerGeneration ||
|
|
882
|
+
record.session.controller.placementInstanceId !== placementInstanceId
|
|
883
|
+
) {
|
|
884
|
+
throw new ComputerSessionOperationConflictError(
|
|
885
|
+
"ComputerSession dispatch controller binding is inconsistent",
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
return record;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function provisionController(
|
|
892
|
+
grant: AccessGrant,
|
|
893
|
+
record: ComputerSessionControlRecord,
|
|
894
|
+
placement: ComputerPlacement,
|
|
895
|
+
origin: string | null,
|
|
896
|
+
): Promise<BrowserControlClient> {
|
|
897
|
+
const adminToken = deriveBrowserControllerAdminToken({
|
|
898
|
+
rootSecret: controllerAuthorityRoot(deps),
|
|
899
|
+
accountId: grant.accountId,
|
|
900
|
+
workspaceId: record.session.workspaceId,
|
|
901
|
+
placement: record.session.placement,
|
|
902
|
+
placementInstanceId: placement.placementInstanceId,
|
|
903
|
+
});
|
|
904
|
+
return (
|
|
905
|
+
await provisionBrowserControlClient(placement.session, {
|
|
906
|
+
adminToken,
|
|
907
|
+
nativeAuthority: nativeControllerAuthority(record.session.workspaceId, placement),
|
|
908
|
+
...(origin ? { allowedOrigins: [origin] } : {}),
|
|
909
|
+
})
|
|
910
|
+
).client;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function releaseInteractionHolder(
|
|
914
|
+
grant: AccessGrant,
|
|
915
|
+
workspaceId: string,
|
|
916
|
+
computerSessionId: string,
|
|
917
|
+
placement: InteractionPlacement,
|
|
918
|
+
): Promise<void> {
|
|
919
|
+
if (placement.kind !== "sandbox_group") return;
|
|
920
|
+
await releaseLeaseHolder(deps.db, {
|
|
921
|
+
accountId: grant.accountId,
|
|
922
|
+
workspaceId,
|
|
923
|
+
sandboxGroupId: placement.sandboxGroupId,
|
|
924
|
+
kind: "interaction",
|
|
925
|
+
holderId: interactionHolderId(computerSessionId),
|
|
926
|
+
idleGraceMs: deps.settings.sandboxIdleGraceMs,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function routePreamble(
|
|
931
|
+
context: Context,
|
|
932
|
+
permission: "sessions:read" | "sessions:control" | "stream:view",
|
|
933
|
+
): Promise<{ workspaceId: string; grant: AccessGrant; computerSessionId: string }> {
|
|
934
|
+
const workspaceId = context.req.param("workspaceId") ?? "";
|
|
935
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, permission);
|
|
936
|
+
return {
|
|
937
|
+
workspaceId,
|
|
938
|
+
grant,
|
|
939
|
+
computerSessionId: requireUuidParam(context, "computerSessionId"),
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function computerCreateInput(
|
|
945
|
+
grant: AccessGrant,
|
|
946
|
+
workspaceId: string,
|
|
947
|
+
request: CreateComputerSessionRequestValue,
|
|
948
|
+
placement: InteractionPlacement,
|
|
949
|
+
) {
|
|
950
|
+
return {
|
|
951
|
+
accountId: grant.accountId,
|
|
952
|
+
workspaceId,
|
|
953
|
+
operationId: request.operationId,
|
|
954
|
+
associatedSessionId: request.sessionId,
|
|
955
|
+
actorSubjectId: grant.subjectId,
|
|
956
|
+
name: request.name ?? "Computer",
|
|
957
|
+
placement,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function assertCreateReplay(
|
|
962
|
+
request: CreateComputerSessionRequestValue,
|
|
963
|
+
session: ComputerSessionValue,
|
|
964
|
+
): void {
|
|
965
|
+
if (request.placement && !sameInteractionPlacement(request.placement, session.placement)) {
|
|
966
|
+
throw new ComputerSessionOperationConflictError(
|
|
967
|
+
"ComputerSession create operation is bound to another placement",
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function sameInteractionPlacement(
|
|
973
|
+
left: InteractionPlacement,
|
|
974
|
+
right: InteractionPlacement,
|
|
975
|
+
): boolean {
|
|
976
|
+
if (left.kind !== right.kind) return false;
|
|
977
|
+
switch (left.kind) {
|
|
978
|
+
case "sandbox_group":
|
|
979
|
+
return right.kind === "sandbox_group" && left.sandboxGroupId === right.sandboxGroupId;
|
|
980
|
+
case "connected_machine":
|
|
981
|
+
return right.kind === "connected_machine" && left.sandboxId === right.sandboxId;
|
|
982
|
+
case "attached_device":
|
|
983
|
+
return right.kind === "attached_device" && left.deviceId === right.deviceId;
|
|
984
|
+
case "external_provider":
|
|
985
|
+
return (
|
|
986
|
+
right.kind === "external_provider" &&
|
|
987
|
+
left.providerId === right.providerId &&
|
|
988
|
+
left.placementId === right.placementId
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function requireOperationGeneration(record: ComputerSessionControlRecord): string {
|
|
994
|
+
const generation = record.operation?.controllerGeneration;
|
|
995
|
+
if (!generation) {
|
|
996
|
+
throw new ComputerSessionStateError("ComputerSession controller fence is absent");
|
|
997
|
+
}
|
|
998
|
+
return generation;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function nativeControllerAuthority(
|
|
1002
|
+
workspaceId: string,
|
|
1003
|
+
placement: ComputerPlacement,
|
|
1004
|
+
): { scopeId: string; scopeGeneration: string } {
|
|
1005
|
+
const placementId =
|
|
1006
|
+
placement.placement.kind === "connected_machine"
|
|
1007
|
+
? placement.placement.sandboxId
|
|
1008
|
+
: placement.placement.kind === "sandbox_group"
|
|
1009
|
+
? placement.placement.sandboxGroupId
|
|
1010
|
+
: null;
|
|
1011
|
+
if (!placementId) {
|
|
1012
|
+
throw new BrowserControlUnsupportedError("computer controller placement is unsupported");
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
scopeId: `${workspaceId}:${placement.placement.kind}:${placementId}`,
|
|
1016
|
+
scopeGeneration: placement.placementInstanceId,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function interactionHolderId(computerSessionId: string): string {
|
|
1021
|
+
return `computer-session:${computerSessionId}`;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function controllerAuthorityRoot(deps: ApiRouteDeps): string {
|
|
1025
|
+
const root = resolveFirstPartyDelegationSecret(deps.settings);
|
|
1026
|
+
if (!root) {
|
|
1027
|
+
throw new HTTPException(503, {
|
|
1028
|
+
message: "interaction controller authority is not configured",
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
return root;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function requireSourceSession(
|
|
1035
|
+
deps: ApiRouteDeps,
|
|
1036
|
+
workspaceId: string,
|
|
1037
|
+
sessionId: string,
|
|
1038
|
+
): Promise<Session> {
|
|
1039
|
+
const session = await getSession(deps.db, workspaceId, sessionId);
|
|
1040
|
+
if (!session) throw new ComputerSessionNotFoundError("Associated session not found");
|
|
1041
|
+
return session;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function authorizeSourceSession(
|
|
1045
|
+
deps: ApiRouteDeps,
|
|
1046
|
+
grant: AccessGrant,
|
|
1047
|
+
sessionId: string,
|
|
1048
|
+
operation: SessionAuthorizationOperation,
|
|
1049
|
+
): Promise<void> {
|
|
1050
|
+
try {
|
|
1051
|
+
await requireSessionAuthorization(deps, grant, {
|
|
1052
|
+
sessionId,
|
|
1053
|
+
operation,
|
|
1054
|
+
surface: "http",
|
|
1055
|
+
});
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (error instanceof SessionAuthorizationDeniedError) {
|
|
1058
|
+
throw new HTTPException(404, { message: "session not found", cause: error });
|
|
1059
|
+
}
|
|
1060
|
+
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
1061
|
+
throw new HTTPException(503, {
|
|
1062
|
+
message: "session authorization is unavailable",
|
|
1063
|
+
cause: error,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function parseJsonBody<T>(
|
|
1071
|
+
context: Context,
|
|
1072
|
+
schema: {
|
|
1073
|
+
safeParse(value: unknown): { success: true; data: T } | { success: false };
|
|
1074
|
+
},
|
|
1075
|
+
): Promise<T> {
|
|
1076
|
+
const value = await context.req.json().catch(() => undefined);
|
|
1077
|
+
const parsed = schema.safeParse(value);
|
|
1078
|
+
if (!parsed.success) throw new HTTPException(400, { message: "invalid request body" });
|
|
1079
|
+
return parsed.data;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function parseEmptyJsonBody(context: Context): Promise<void> {
|
|
1083
|
+
const text = await context.req.text();
|
|
1084
|
+
if (!text.trim()) return;
|
|
1085
|
+
try {
|
|
1086
|
+
const value = JSON.parse(text) as unknown;
|
|
1087
|
+
if (
|
|
1088
|
+
typeof value !== "object" ||
|
|
1089
|
+
value === null ||
|
|
1090
|
+
Array.isArray(value) ||
|
|
1091
|
+
Object.keys(value).length !== 0
|
|
1092
|
+
) {
|
|
1093
|
+
throw new Error("not empty");
|
|
1094
|
+
}
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new HTTPException(400, { message: "invalid request body" });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function requireUuidParam(context: Context, name: string): string {
|
|
1101
|
+
const value = context.req.param(name) ?? "";
|
|
1102
|
+
if (!isUuid(value)) throw new HTTPException(404, { message: "ComputerSession not found" });
|
|
1103
|
+
return value;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function requireOpaqueParam(context: Context, name: string): string {
|
|
1107
|
+
const value = context.req.param(name) ?? "";
|
|
1108
|
+
if (value.length < 1 || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
1109
|
+
throw new HTTPException(404, { message: "computer target not found" });
|
|
1110
|
+
}
|
|
1111
|
+
return value;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function requestOrigin(context: Context, allowedPattern: string): string | null {
|
|
1115
|
+
const value = context.req.header("origin");
|
|
1116
|
+
if (!value) return null;
|
|
1117
|
+
let url: URL;
|
|
1118
|
+
try {
|
|
1119
|
+
url = new URL(value);
|
|
1120
|
+
} catch {
|
|
1121
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
1122
|
+
}
|
|
1123
|
+
if (
|
|
1124
|
+
url.origin === "null" ||
|
|
1125
|
+
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
1126
|
+
url.origin !== value
|
|
1127
|
+
) {
|
|
1128
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
1129
|
+
}
|
|
1130
|
+
if (!allowedCorsOrigin(allowedPattern, url.origin)) {
|
|
1131
|
+
throw new HTTPException(403, { message: "request origin is not allowed" });
|
|
1132
|
+
}
|
|
1133
|
+
return url.origin;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function interactionActorForGrant(grant: AccessGrant): ReturnType<typeof InteractionActor.parse> {
|
|
1137
|
+
if (grant.principalKind !== "agent_attempt") {
|
|
1138
|
+
return InteractionActor.parse({
|
|
1139
|
+
kind: grant.principalKind === "service" ? "system" : "human",
|
|
1140
|
+
subjectId: grant.subjectId,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return InteractionActor.parse({
|
|
1144
|
+
kind: "agent",
|
|
1145
|
+
subjectId: grant.subjectId,
|
|
1146
|
+
sessionId: grant.metadata?.["sessionId"],
|
|
1147
|
+
turnId: grant.metadata?.["turnId"],
|
|
1148
|
+
attemptId: grant.metadata?.["attemptId"],
|
|
1149
|
+
executionGeneration: grant.metadata?.["executionGeneration"],
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function assertPlacementInstance(expected: string | null, actual: string): void {
|
|
1154
|
+
if (expected !== null && expected !== actual) {
|
|
1155
|
+
throw new ComputerSessionStateError("ComputerSession placement instance changed");
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function isTerminalOperation(state: string): boolean {
|
|
1160
|
+
return state === "completed" || state === "failed" || state === "outcome_unknown";
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function interactionFailure(error: unknown) {
|
|
1164
|
+
if (error instanceof BrowserControlRequestError) return error.error;
|
|
1165
|
+
if (
|
|
1166
|
+
error instanceof BrowserControlUnsupportedError ||
|
|
1167
|
+
error instanceof BrowserControlServerUnsupportedError
|
|
1168
|
+
) {
|
|
1169
|
+
return { code: "unsupported" as const, message: error.message, retryable: false };
|
|
1170
|
+
}
|
|
1171
|
+
if (error instanceof BrowserControlServerError) {
|
|
1172
|
+
return {
|
|
1173
|
+
code:
|
|
1174
|
+
error.stage === "engine_unavailable"
|
|
1175
|
+
? ("unsupported" as const)
|
|
1176
|
+
: ("driver_failed" as const),
|
|
1177
|
+
message: error.message,
|
|
1178
|
+
retryable: error.stage !== "engine_unavailable",
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
code: "driver_failed" as const,
|
|
1183
|
+
message: error instanceof Error ? error.message : "computer controller failed",
|
|
1184
|
+
retryable: false,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function computerRouteError(error: unknown): HTTPException {
|
|
1189
|
+
if (error instanceof HTTPException) return error;
|
|
1190
|
+
if (error instanceof ComputerSessionNotFoundError) {
|
|
1191
|
+
return new HTTPException(404, { message: error.message, cause: error });
|
|
1192
|
+
}
|
|
1193
|
+
if (
|
|
1194
|
+
error instanceof ComputerSessionOperationConflictError ||
|
|
1195
|
+
error instanceof ComputerSessionStateError
|
|
1196
|
+
) {
|
|
1197
|
+
return new HTTPException(409, { message: error.message, cause: error });
|
|
1198
|
+
}
|
|
1199
|
+
if (error instanceof BrowserControlRequestError) {
|
|
1200
|
+
return new HTTPException(error.status as ContentfulStatusCode, {
|
|
1201
|
+
message: error.error.message,
|
|
1202
|
+
cause: error,
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
if (error instanceof BrowserControlTransportError) {
|
|
1206
|
+
return new HTTPException(503, {
|
|
1207
|
+
message: "computer controller is unavailable",
|
|
1208
|
+
cause: error,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
if (error instanceof BrowserControlProtocolError) {
|
|
1212
|
+
return new HTTPException(502, {
|
|
1213
|
+
message: "computer controller response is invalid",
|
|
1214
|
+
cause: error,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
if (
|
|
1218
|
+
error instanceof BrowserControlUnsupportedError ||
|
|
1219
|
+
error instanceof BrowserControlServerUnsupportedError
|
|
1220
|
+
) {
|
|
1221
|
+
return new HTTPException(409, { message: error.message, cause: error });
|
|
1222
|
+
}
|
|
1223
|
+
if (error instanceof BrowserControlServerError) {
|
|
1224
|
+
return new HTTPException(error.stage === "engine_unavailable" ? 409 : 503, {
|
|
1225
|
+
message:
|
|
1226
|
+
error.stage === "engine_unavailable"
|
|
1227
|
+
? "computer adapter is unavailable on this placement"
|
|
1228
|
+
: "computer controller could not start",
|
|
1229
|
+
cause: error,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
return new HTTPException(500, {
|
|
1233
|
+
message: "ComputerSession request failed",
|
|
1234
|
+
cause: error,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function isUuid(value: unknown): value is string {
|
|
1239
|
+
return (
|
|
1240
|
+
typeof value === "string" &&
|
|
1241
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function isAbort(error: unknown): boolean {
|
|
1246
|
+
return error instanceof Error && error.name === "AbortError";
|
|
1247
|
+
}
|