@opengeni/api-router 0.2.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 +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
// apps/api/src/sandbox/viewer.ts — the API-DIRECT viewer-holder lifecycle (P1.4).
|
|
2
|
+
//
|
|
3
|
+
// A viewer (a human watching a session's box) acquires a `viewer` holder on the
|
|
4
|
+
// GROUP lease so the box stays warm WHILE WATCHED — liveness = turn OR viewer
|
|
5
|
+
// (the §C group-refcount win). All IN-PROCESS: the API runs the cold->warming
|
|
6
|
+
// CAS as a Postgres txn it owns, resumes the box BY ID via the leaf
|
|
7
|
+
// (@opengeni/runtime/sandbox), and never signals Temporal or a worker.
|
|
8
|
+
//
|
|
9
|
+
// attach -> acquireLease(kind:'viewer') under FOR UPDATE + cold->warming CAS.
|
|
10
|
+
// spawner role -> establish the box in-process + commitWarmingToWarm.
|
|
11
|
+
// attached/rearmed -> the holder alone keeps the box warm.
|
|
12
|
+
// fenced -> release + surface a 409 (a newer epoch re-established it).
|
|
13
|
+
// heartbeat -> heartbeatLeaseHolder (epoch-fenced) refreshes the holder TTL.
|
|
14
|
+
// detach -> releaseLeaseHolder (idempotent); the reaper (P1.3) stop()s the
|
|
15
|
+
// box at refcount 0 past the drain grace.
|
|
16
|
+
//
|
|
17
|
+
// The desktop pixel tunnel-URL mint + the un-redacted acknowledgment + the
|
|
18
|
+
// scoped token are P3/P4. Here we surface only the holder lifecycle + the
|
|
19
|
+
// lease's recorded data_plane_url (null until P4 mints it).
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { applyGitAuthPointerEnvironment, hasGitHubRepositorySelection, resolveStreamTokenSecret, stableSandboxEnvironmentForRun } from "@opengeni/config";
|
|
23
|
+
import type { Settings } from "@opengeni/config";
|
|
24
|
+
import { githubAppBotIdentity } from "@opengeni/github";
|
|
25
|
+
import { type Session, type StreamUrlRotatedPayload } from "@opengeni/contracts";
|
|
26
|
+
import {
|
|
27
|
+
acquireLease,
|
|
28
|
+
commitWarmingToWarm,
|
|
29
|
+
failWarmingToCold,
|
|
30
|
+
getSandbox,
|
|
31
|
+
getSandboxSessionEnvelope,
|
|
32
|
+
heartbeatLeaseHolder,
|
|
33
|
+
loadWorkspaceEnvironmentForRun,
|
|
34
|
+
readLease,
|
|
35
|
+
recordLeaseDataPlaneUrl,
|
|
36
|
+
recordLeaseTerminalDataPlaneUrl,
|
|
37
|
+
releaseLeaseHolder,
|
|
38
|
+
SandboxLeaseSupersededError,
|
|
39
|
+
type Database,
|
|
40
|
+
type LeaseSnapshot,
|
|
41
|
+
type SandboxRecord,
|
|
42
|
+
} from "@opengeni/db";
|
|
43
|
+
import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
|
|
44
|
+
import { HTTPException } from "hono/http-exception";
|
|
45
|
+
|
|
46
|
+
// The leaf — agent-loop-free. apps/api imports sandbox symbols ONLY from here
|
|
47
|
+
// (enforced by sandbox-access-import-guard.test.ts).
|
|
48
|
+
import {
|
|
49
|
+
DESKTOP_STREAM_PORT,
|
|
50
|
+
ensureDisplayStack,
|
|
51
|
+
ensureTerminalServer,
|
|
52
|
+
establishSandboxSessionFromEnvelope,
|
|
53
|
+
exposeStreamPort,
|
|
54
|
+
desktopCapableBackend,
|
|
55
|
+
NatsControlRpc,
|
|
56
|
+
SelfhostedSandboxClient,
|
|
57
|
+
serializeEstablishedSandboxEnvelope,
|
|
58
|
+
mintStreamToken,
|
|
59
|
+
STREAM_TOKEN_DEFAULT_TTL_SECONDS,
|
|
60
|
+
TERMINAL_STREAM_PORT,
|
|
61
|
+
DisplayStackUnsupportedError,
|
|
62
|
+
TerminalServerUnsupportedError,
|
|
63
|
+
StreamPortUnavailableError,
|
|
64
|
+
type ControlRpc,
|
|
65
|
+
type EstablishedSandboxSession,
|
|
66
|
+
type NatsRequestConnection,
|
|
67
|
+
} from "@opengeni/runtime/sandbox";
|
|
68
|
+
import { relayConfigFromSettings } from "@opengeni/core";
|
|
69
|
+
|
|
70
|
+
/** The minimal services a viewer op needs: the DB + settings (lease cadence +
|
|
71
|
+
* the sandbox client construction the leaf reads from settings). The bus is
|
|
72
|
+
* optional — only the rotation path (emitting stream.url.rotated to OTHER
|
|
73
|
+
* viewers) needs it. */
|
|
74
|
+
export type ViewerServices = {
|
|
75
|
+
db: Database;
|
|
76
|
+
settings: Settings;
|
|
77
|
+
bus?: EventBus;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A coherent snapshot the routes echo back: the holder id (the viewer's fence-
|
|
81
|
+
* carrying handle), the lease liveness/epoch, and the recorded data-plane URL
|
|
82
|
+
* (null until P4 mints the desktop tunnel). */
|
|
83
|
+
export type ViewerAttachResult = {
|
|
84
|
+
viewerId: string;
|
|
85
|
+
liveness: LeaseSnapshot["liveness"];
|
|
86
|
+
leaseEpoch: number;
|
|
87
|
+
sandboxGroupId: string;
|
|
88
|
+
// The viewer heartbeat cadence the client must beat at to keep the holder
|
|
89
|
+
// alive (shorter than the viewer-holder TTL the reaper enforces).
|
|
90
|
+
viewerHeartbeatIntervalMs: number;
|
|
91
|
+
// The desktop pixel tunnel URL the viewer connects to directly. Null in P1.4
|
|
92
|
+
// (the mint is P4); surfaced here so the shape is stable.
|
|
93
|
+
dataPlaneUrl: string | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The STABLE run-scoped sandbox environment a COLD box must be created with so
|
|
98
|
+
* that — whether the box is first warmed by an API-direct ATTACH (here) or by the
|
|
99
|
+
* worker TURN — its manifest environment matches the environment the agent later
|
|
100
|
+
* declares for a turn. Without this, an attach-warmed box was created with the
|
|
101
|
+
* BASE allowlist env only (establishSandboxSessionFromEnvelope's
|
|
102
|
+
* collectSandboxEnvironment default), so the next turn's fuller env (git identity
|
|
103
|
+
* + workspace environment + HOME) introduced a delta and the SDK's
|
|
104
|
+
* `validateNoEnvironmentDelta` threw "Live sandbox sessions cannot change manifest
|
|
105
|
+
* environment variables" — the BLOCKING error this fixes.
|
|
106
|
+
*
|
|
107
|
+
* Mirrors the worker turn's STABLE env (config.stableSandboxEnvironmentForRun +
|
|
108
|
+
* the session's attached, decrypted workspace environment + — for a repo-attached
|
|
109
|
+
* session — the stable git-auth POINTERS the turn declares since the token-broker:
|
|
110
|
+
* GIT_ASKPASS / GIT_TERMINAL_PROMPT / bot identity). The pointers carry NO rotating
|
|
111
|
+
* value (the token lives in the box FILE the clone hook seeds), so they are
|
|
112
|
+
* attach-reproducible; omitting them cold-created a box whose env lacked keys the
|
|
113
|
+
* next repo turn's manifest declares → the SDK guard threw "Live sandbox sessions
|
|
114
|
+
* cannot change manifest environment variables" whenever a viewer attach (an open
|
|
115
|
+
* session page) won the cold-create race against the first turn.
|
|
116
|
+
*/
|
|
117
|
+
export async function sessionAttachEnvironment(
|
|
118
|
+
services: ViewerServices,
|
|
119
|
+
workspaceId: string,
|
|
120
|
+
session: Session,
|
|
121
|
+
): Promise<Record<string, string>> {
|
|
122
|
+
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
|
|
123
|
+
services.db,
|
|
124
|
+
services.settings,
|
|
125
|
+
workspaceId,
|
|
126
|
+
session.environmentId,
|
|
127
|
+
);
|
|
128
|
+
// Build the env with the SESSION's backend, not the deployment default: the
|
|
129
|
+
// stable base is backend-aware (HOME = the descriptor workspaceRoot, and the
|
|
130
|
+
// git token-file/askpass pointers derive from HOME), the box is established
|
|
131
|
+
// with `backendOverride: session.sandboxBackend`, and the worker turn builds
|
|
132
|
+
// the same env from runSettings.sandboxBackend = the session's backend. An
|
|
133
|
+
// attach env keyed off the deployment default would cold-create e.g. an e2b
|
|
134
|
+
// session's box with /workspace-rooted values while its turn declares
|
|
135
|
+
// /home/user-rooted ones — the same guard-killed first turn all over again.
|
|
136
|
+
const settingsForSession = session.sandboxBackend !== services.settings.sandboxBackend
|
|
137
|
+
? { ...services.settings, sandboxBackend: session.sandboxBackend }
|
|
138
|
+
: services.settings;
|
|
139
|
+
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
|
|
140
|
+
if (hasGitHubRepositorySelection(session.resources)) {
|
|
141
|
+
applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(services.settings));
|
|
142
|
+
}
|
|
143
|
+
return environment;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Acquire a `viewer` holder on the group lease, spinning up the box IN-PROCESS
|
|
148
|
+
* when cold. Mirrors the worker's resumeBoxForTurn spawner/attached branches,
|
|
149
|
+
* but with kind:'viewer' and run by the API process — no Temporal, no worker.
|
|
150
|
+
*
|
|
151
|
+
* `viewerId` is the unique-per-connection holder id (a uuid the client carries
|
|
152
|
+
* through heartbeats + detach); generated when absent.
|
|
153
|
+
*/
|
|
154
|
+
export async function attachViewer(
|
|
155
|
+
services: ViewerServices,
|
|
156
|
+
input: { accountId: string; workspaceId: string; session: Session; viewerId?: string },
|
|
157
|
+
): Promise<ViewerAttachResult> {
|
|
158
|
+
const { db, settings } = services;
|
|
159
|
+
const { accountId, workspaceId, session } = input;
|
|
160
|
+
const viewerId = input.viewerId ?? crypto.randomUUID();
|
|
161
|
+
const leaseTtlMs = settings.sandboxLeaseTtlMs;
|
|
162
|
+
const sandboxGroupId = session.sandboxGroupId;
|
|
163
|
+
|
|
164
|
+
const release = async (): Promise<void> => {
|
|
165
|
+
await releaseLeaseHolder(db, {
|
|
166
|
+
accountId,
|
|
167
|
+
workspaceId,
|
|
168
|
+
sandboxGroupId,
|
|
169
|
+
kind: "viewer",
|
|
170
|
+
holderId: viewerId,
|
|
171
|
+
idleGraceMs: settings.sandboxIdleGraceMs,
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const acquired = await acquireLease(db, {
|
|
176
|
+
accountId,
|
|
177
|
+
workspaceId,
|
|
178
|
+
sandboxGroupId,
|
|
179
|
+
kind: "viewer",
|
|
180
|
+
holderId: viewerId,
|
|
181
|
+
subjectId: session.id,
|
|
182
|
+
backend: session.sandboxBackend,
|
|
183
|
+
os: session.sandboxOs,
|
|
184
|
+
leaseTtlMs,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// FENCED: a newer epoch re-established the box. Release our just-registered
|
|
188
|
+
// holder and surface a 409 — the client re-reads capabilities and re-attaches.
|
|
189
|
+
if (acquired.role === "fenced") {
|
|
190
|
+
await release();
|
|
191
|
+
throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); re-read capabilities and re-attach` });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// SPAWNER: we won the cold->warming CAS. Establish the box in-process from the
|
|
195
|
+
// session's persisted envelope (warm reattach by id, or cold-restore on a
|
|
196
|
+
// provider NotFound), then commit warm (the lease_epoch++ fence + fold the
|
|
197
|
+
// resume envelope onto the lease). A held in-memory handle is dropped after
|
|
198
|
+
// commit — the lease owns lifecycle, not this handle (non-owned by id).
|
|
199
|
+
if (acquired.role === "spawner") {
|
|
200
|
+
const expectedEpoch = acquired.lease.leaseEpoch;
|
|
201
|
+
let established: EstablishedSandboxSession | undefined;
|
|
202
|
+
try {
|
|
203
|
+
const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
|
|
204
|
+
// Create a cold box with the SAME stable run-environment the worker turn
|
|
205
|
+
// will declare (config base + git identity + decrypted workspace env + HOME)
|
|
206
|
+
// so the next turn's agent-manifest apply finds an EMPTY environment delta in
|
|
207
|
+
// the SDK's validateNoEnvironmentDelta (otherwise: "Live sandbox sessions
|
|
208
|
+
// cannot change manifest environment variables").
|
|
209
|
+
const environment = await sessionAttachEnvironment(services, workspaceId, session);
|
|
210
|
+
// Prefer the COLD lease's preserved resume_state when it carries a persisted
|
|
211
|
+
// /workspace snapshot (confirmDrainCold keeps a minimal archive-only envelope
|
|
212
|
+
// across draining->cold). establishSandboxSessionFromEnvelope cold-creates a
|
|
213
|
+
// fresh box and replays the archive via hydrateWorkspace, so /workspace
|
|
214
|
+
// survives the box churn (sandbox-file-persistence). No archive -> the bare
|
|
215
|
+
// session envelope (a never-warmed cold start).
|
|
216
|
+
const spawnEnvelope = acquired.lease.resumeState ?? envelope;
|
|
217
|
+
established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
|
|
218
|
+
sessionId: session.id,
|
|
219
|
+
backendOverride: session.sandboxBackend,
|
|
220
|
+
environment,
|
|
221
|
+
});
|
|
222
|
+
// Fold the LIVE box into a re-resumable envelope and persist it as the
|
|
223
|
+
// lease's resume_state, so EVERY later op (another viewer, a Channel-A
|
|
224
|
+
// call, the reaper) resumes THIS box by id instead of cold-creating a
|
|
225
|
+
// rival. Fall back to the session envelope only when serialize is
|
|
226
|
+
// unavailable. (Without this the box churned: each op spawned its own box.)
|
|
227
|
+
const resumeEnvelope = (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
|
|
228
|
+
const committed = await commitWarmingToWarm(db, {
|
|
229
|
+
accountId,
|
|
230
|
+
workspaceId,
|
|
231
|
+
sandboxGroupId,
|
|
232
|
+
expectedEpoch,
|
|
233
|
+
instanceId: established.instanceId,
|
|
234
|
+
// The desktop tunnel-URL mint is P4; record null for now.
|
|
235
|
+
dataPlaneUrl: null,
|
|
236
|
+
resumeBackendId: established.backendId,
|
|
237
|
+
resumeState: resumeEnvelope,
|
|
238
|
+
leaseTtlMs,
|
|
239
|
+
});
|
|
240
|
+
if (!committed.committed || !committed.lease) {
|
|
241
|
+
// A reaper reset our warming row (we were too slow) or a sibling
|
|
242
|
+
// re-established and bumped the epoch. Release our holder and surface a
|
|
243
|
+
// 409. NEVER provider-delete the box (it rides the provider idle-timeout).
|
|
244
|
+
await release();
|
|
245
|
+
throw new SandboxLeaseSupersededError(sandboxGroupId, expectedEpoch);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
viewerId,
|
|
249
|
+
liveness: committed.lease.liveness,
|
|
250
|
+
leaseEpoch: committed.lease.leaseEpoch,
|
|
251
|
+
sandboxGroupId,
|
|
252
|
+
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
253
|
+
dataPlaneUrl: committed.lease.dataPlaneUrl,
|
|
254
|
+
};
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof SandboxLeaseSupersededError) {
|
|
257
|
+
throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${error.leaseEpoch}); re-read capabilities and re-attach` });
|
|
258
|
+
}
|
|
259
|
+
// Caught spawn failure: roll the warming row back to cold so the next
|
|
260
|
+
// arrival (a turn or another viewer) re-acquires and re-spawns. Holders
|
|
261
|
+
// are intentionally kept by failWarmingToCold for the re-acquire; then
|
|
262
|
+
// release our own holder so we don't pin a cold lease.
|
|
263
|
+
await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
|
|
264
|
+
await release();
|
|
265
|
+
// Mirror the Channel-A spawner (channel-a.ts): a provider/config failure to
|
|
266
|
+
// bring up the cold box is a client-actionable 409 ("sandbox not available;
|
|
267
|
+
// re-attach to retry"), NOT a raw 500 — the warming row was just rolled back
|
|
268
|
+
// to cold, so a re-attach re-acquires and re-spawns. Preserve an already-typed
|
|
269
|
+
// HTTPException unchanged.
|
|
270
|
+
if (error instanceof HTTPException) throw error;
|
|
271
|
+
throw new HTTPException(409, { message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})` });
|
|
272
|
+
} finally {
|
|
273
|
+
// Drop the in-process handle: the API resumed BY ID for the cold-spawn,
|
|
274
|
+
// it does NOT own the box. The lease's refcount (this viewer holder) keeps
|
|
275
|
+
// it warm; the reaper stops it at refcount 0.
|
|
276
|
+
await dropEstablishedHandle(established);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ATTACHED / REARMED: the box is live (or a sibling is mid-warm). The viewer
|
|
281
|
+
// holder alone keeps it warm — no establish needed (the holder lifecycle is
|
|
282
|
+
// the P1.4 deliverable; P4 mints the pixel URL on the negotiation read).
|
|
283
|
+
return {
|
|
284
|
+
viewerId,
|
|
285
|
+
liveness: acquired.lease.liveness,
|
|
286
|
+
leaseEpoch: acquired.lease.leaseEpoch,
|
|
287
|
+
sandboxGroupId,
|
|
288
|
+
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
289
|
+
dataPlaneUrl: acquired.lease.dataPlaneUrl,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Refresh a viewer holder's TTL (the app-level viewer heartbeat). Epoch-fenced:
|
|
295
|
+
* a stale-epoch heartbeat (a box re-established under a newer epoch) returns
|
|
296
|
+
* false and the client must re-attach. Returns whether the holder is still live.
|
|
297
|
+
*/
|
|
298
|
+
export async function heartbeatViewer(
|
|
299
|
+
services: ViewerServices,
|
|
300
|
+
input: { accountId: string; workspaceId: string; sandboxGroupId: string; viewerId: string; expectedEpoch: number },
|
|
301
|
+
): Promise<boolean> {
|
|
302
|
+
return await heartbeatLeaseHolder(services.db, {
|
|
303
|
+
accountId: input.accountId,
|
|
304
|
+
workspaceId: input.workspaceId,
|
|
305
|
+
sandboxGroupId: input.sandboxGroupId,
|
|
306
|
+
kind: "viewer",
|
|
307
|
+
holderId: input.viewerId,
|
|
308
|
+
leaseTtlMs: services.settings.sandboxLeaseTtlMs,
|
|
309
|
+
expectedEpoch: input.expectedEpoch,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Release a viewer holder (the client disconnected). Idempotent: a double
|
|
315
|
+
* detach (or a detach after the reaper already TTL-reaped the holder) is a
|
|
316
|
+
* no-op. The box drains/stops only when no turn AND no viewer holds it.
|
|
317
|
+
*/
|
|
318
|
+
export async function detachViewer(
|
|
319
|
+
services: ViewerServices,
|
|
320
|
+
input: { accountId: string; workspaceId: string; sandboxGroupId: string; viewerId: string },
|
|
321
|
+
): Promise<{ liveness: LeaseSnapshot["liveness"]; refcount: number } | null> {
|
|
322
|
+
return await releaseLeaseHolder(services.db, {
|
|
323
|
+
accountId: input.accountId,
|
|
324
|
+
workspaceId: input.workspaceId,
|
|
325
|
+
sandboxGroupId: input.sandboxGroupId,
|
|
326
|
+
kind: "viewer",
|
|
327
|
+
holderId: input.viewerId,
|
|
328
|
+
idleGraceMs: services.settings.sandboxIdleGraceMs,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Non-locking lease snapshot for the capability-negotiation read. */
|
|
333
|
+
export async function readGroupLease(
|
|
334
|
+
services: ViewerServices,
|
|
335
|
+
input: { workspaceId: string; sandboxGroupId: string },
|
|
336
|
+
): Promise<LeaseSnapshot | null> {
|
|
337
|
+
return await readLease(services.db, input.workspaceId, input.sandboxGroupId);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The viewer heartbeat cadence: half the viewer-holder TTL, floored at 5s, so a
|
|
341
|
+
// single missed beat never reaps a live viewer (two beats fit inside the TTL).
|
|
342
|
+
export function viewerHeartbeatIntervalMs(settings: Settings): number {
|
|
343
|
+
return Math.max(5_000, Math.floor(settings.sandboxViewerHolderTtlMs / 2));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Drop a transiently-established, NON-OWNED handle. The box is owned by the LEASE
|
|
347
|
+
// (resumed by id), not by this in-process handle, so we MUST NOT terminate it on
|
|
348
|
+
// drop — we only release the local reference and let GC reclaim the client's
|
|
349
|
+
// transport. This mirrors the worker's resume-by-id path (sandbox-resume.ts),
|
|
350
|
+
// which injects the session NON-OWNED and never closes it.
|
|
351
|
+
//
|
|
352
|
+
// CRITICAL (deployed-integration bug, prove-it D1/D2/D5): a provider session's
|
|
353
|
+
// `close()` is NOT a neutral "free local resources" call. For Modal,
|
|
354
|
+
// `ModalSandboxSession.close()` calls `sandbox.terminate()` — it KILLS THE BOX.
|
|
355
|
+
// Calling it here terminated the very box the lease had just committed warm, so
|
|
356
|
+
// every viewer attach / Channel-A op spawned a box and immediately destroyed it
|
|
357
|
+
// (the lease showed warm while Modal showed the box gone; reads 404'd against a
|
|
358
|
+
// fresh box). We therefore DO NOT call session.close()/shutdown()/delete() — the
|
|
359
|
+
// reaper (provider stop at refcount 0) is the ONLY sanctioned box terminator.
|
|
360
|
+
async function dropEstablishedHandle(established: EstablishedSandboxSession | undefined): Promise<void> {
|
|
361
|
+
// Intentionally a no-op beyond dropping the reference: terminating the box here
|
|
362
|
+
// is wrong (see above). The lease owns lifecycle; the reaper owns teardown.
|
|
363
|
+
void established;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// P4.2 — the pixel DATA PLANE, served API-DIRECT.
|
|
368
|
+
//
|
|
369
|
+
// mintDesktopStream resumes the WARM box BY ID in-process, idempotently ensures
|
|
370
|
+
// the display stack, resolves the provider's scoped tunnel for port 6080, mints
|
|
371
|
+
// the scoped per-viewer stream token, records the resolved URL on the lease under
|
|
372
|
+
// the epoch fence, and (on a box rollover — a lease_epoch advance vs what the
|
|
373
|
+
// caller last saw) emits a `stream.url.rotated` Channel-A event so OTHER
|
|
374
|
+
// connected viewers reconnect. NO Temporal, NO worker, NO NATS req/reply: the API
|
|
375
|
+
// process holds the live handle for the duration of the call and drops it on
|
|
376
|
+
// return (the lease, not this handle, owns the box).
|
|
377
|
+
//
|
|
378
|
+
// Rotation is EVENT-DRIVEN, not a timer: the URL only changes when the box is
|
|
379
|
+
// re-keyed (Modal 24h ceiling / death → re-establish under a new epoch). The
|
|
380
|
+
// requester always gets the fresh cell as the HTTP response; the rotation event
|
|
381
|
+
// is the out-of-band signal to the OTHER viewers of the same session.
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
/** The minted pixel cell the handshake/attach folds into the DesktopStream
|
|
385
|
+
* capability. Null when degraded (no secret, headless backend, display-stack
|
|
386
|
+
* failure, provider tunnel failure) — degradation is a value, never a throw. */
|
|
387
|
+
export type DesktopStreamMint = {
|
|
388
|
+
url: string;
|
|
389
|
+
token: string;
|
|
390
|
+
expiresAt: string;
|
|
391
|
+
resolution: [number, number];
|
|
392
|
+
leaseEpoch: number;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export type MintDesktopStreamInput = {
|
|
396
|
+
accountId: string;
|
|
397
|
+
workspaceId: string;
|
|
398
|
+
session: Session;
|
|
399
|
+
/** The viewer holder id the scoped token is minted for. */
|
|
400
|
+
viewerId: string;
|
|
401
|
+
/** The live lease (must be warm/draining — the box is up). A selfhosted-active
|
|
402
|
+
* session may have no Modal group lease; omit and the selfhosted branch handles it. */
|
|
403
|
+
lease?: LeaseSnapshot;
|
|
404
|
+
/** The epoch the CALLER last observed the URL minted under. When the live
|
|
405
|
+
* lease epoch is greater, the box rolled over → emit stream.url.rotated to the
|
|
406
|
+
* other viewers. Omit on a first mint (no prior URL to rotate from). */
|
|
407
|
+
previousEpoch?: number;
|
|
408
|
+
/** Test seam: override how the box is re-established by id. Defaults to the
|
|
409
|
+
* real leaf `establishSandboxSessionFromEnvelope`. Production NEVER passes
|
|
410
|
+
* this; it exists so a real-lease integration test can inject a fake provider
|
|
411
|
+
* session carrying `resolveExposedPort` without a live cloud box. */
|
|
412
|
+
establish?: (
|
|
413
|
+
envelope: Record<string, unknown> | null,
|
|
414
|
+
) => Promise<EstablishedSandboxSession>;
|
|
415
|
+
/** Test seam: inject a fake relay-resolving session for the selfhosted-active
|
|
416
|
+
* branch. Production NEVER passes this. */
|
|
417
|
+
resolveSelfhostedSession?: (sandbox: SandboxRecord) => Promise<{ resolveExposedPort?: (port: number) => Promise<unknown> }>;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Mint (or re-mint) the desktop pixel cell for a viewer against a WARM box,
|
|
422
|
+
* IN-PROCESS. Returns the minted cell, or null when the desktop tier degrades
|
|
423
|
+
* (no resolvable stream-token secret, a headless backend, a display-stack
|
|
424
|
+
* failure, or a provider-tunnel failure) — the caller surfaces transport:null,
|
|
425
|
+
* never an exception to the user.
|
|
426
|
+
*
|
|
427
|
+
* Idempotent display-stack + resolveExposedPort are safe to call N times. The
|
|
428
|
+
* resolved URL is recorded on the lease (data_plane_url) under the epoch fence; a
|
|
429
|
+
* stale-epoch write (the box re-established under a newer epoch mid-call) is a
|
|
430
|
+
* no-op and we return the freshly-minted cell anyway (it is for the epoch we
|
|
431
|
+
* resumed under; the next op reconciles).
|
|
432
|
+
*/
|
|
433
|
+
export async function mintDesktopStream(
|
|
434
|
+
services: ViewerServices,
|
|
435
|
+
input: MintDesktopStreamInput,
|
|
436
|
+
): Promise<DesktopStreamMint | null> {
|
|
437
|
+
const { db, settings, bus } = services;
|
|
438
|
+
const { accountId, workspaceId, session } = input;
|
|
439
|
+
const lease = input.lease;
|
|
440
|
+
// The scoped token's viewerId must be a UUID (StreamTokenPayload). The GET caps
|
|
441
|
+
// handshake passes grant.subjectId, which is a non-UUID for an API-key principal
|
|
442
|
+
// ("configured:key") — coerce it to a deterministic UUID so the mint never 500s
|
|
443
|
+
// (caps-500 fix). A managed-session subject (already a UUID) is unchanged.
|
|
444
|
+
const viewerId = viewerIdAsUuid(input.viewerId);
|
|
445
|
+
|
|
446
|
+
// GATE 1: a desktop tier that is off, headless, or lacks a stream-token secret
|
|
447
|
+
// cannot mint a live URL. (The handshake's negotiateCapabilities already
|
|
448
|
+
// reports the typed reason; here we just refuse to mint.)
|
|
449
|
+
if (!settings.sandboxDesktopEnabled) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
if (!desktopCapableBackend(session.sandboxBackend)) {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const secret = resolveStreamTokenSecret(settings);
|
|
456
|
+
if (!secret) {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// SELFHOSTED ACTIVE: when the session's active sandbox is a selfhosted machine,
|
|
461
|
+
// route to the relay (NOT the Modal group-box path — it would resume the wrong
|
|
462
|
+
// box and return a Modal URL). No Modal lease required.
|
|
463
|
+
if (session.activeSandboxId) {
|
|
464
|
+
const active = await getSandbox(db, workspaceId, session.activeSandboxId);
|
|
465
|
+
if (active?.kind === "selfhosted") {
|
|
466
|
+
const m = await tryMintActiveSelfhostedStream(services, { session, viewerId: input.viewerId, workspaceId, port: DESKTOP_STREAM_PORT, sandbox: active }, input.resolveSelfhostedSession);
|
|
467
|
+
// mintSelfhostedStream returns no resolution; the desktop cell needs it.
|
|
468
|
+
return m ? { url: m.url, token: m.token, expiresAt: m.expiresAt, resolution: defaultResolution(settings), leaseEpoch: m.leaseEpoch } : null;
|
|
469
|
+
}
|
|
470
|
+
// A Modal swap target (or unknown) falls through to the existing group-box path.
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// GATE 2: the box must be live (the handshake never spins one up — a cold box
|
|
474
|
+
// returns lease_cold; the viewer-attach path warms it first, then mints).
|
|
475
|
+
if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// FAST PATH (P4.2 perf): when the lease already holds the data-plane URL for
|
|
480
|
+
// this epoch, the box is warm, exposed, and the display stack is already up.
|
|
481
|
+
// Re-resuming the box by id (Modal resume-by-id is ~40s) + re-running
|
|
482
|
+
// ensureDisplayStack + exposeStreamPort on EVERY stream-capabilities poll made
|
|
483
|
+
// the desktop look like it was "starting" forever. The tunnel URL is stable for
|
|
484
|
+
// the life of the (epoch-fenced) box, so mint ONLY a fresh scoped token (HMAC,
|
|
485
|
+
// sub-millisecond) against the cached URL and return — no box touch at all. A
|
|
486
|
+
// rollover advances the epoch and re-records dataPlaneUrl via the slow path, so
|
|
487
|
+
// a cached URL here is always the current epoch's live tunnel.
|
|
488
|
+
if (lease.dataPlaneUrl) {
|
|
489
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
490
|
+
const token = await mintStreamToken(secret, {
|
|
491
|
+
workspaceId,
|
|
492
|
+
sessionId: session.id,
|
|
493
|
+
viewerId,
|
|
494
|
+
leaseEpoch: lease.leaseEpoch,
|
|
495
|
+
nowSeconds,
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
url: lease.dataPlaneUrl,
|
|
499
|
+
token,
|
|
500
|
+
expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
|
|
501
|
+
resolution: defaultResolution(settings),
|
|
502
|
+
leaseEpoch: lease.leaseEpoch,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Resume the LIVE box by id. The lease's resume_state is authoritative (it is
|
|
507
|
+
// the box the lease currently fences); fall back to the session envelope only
|
|
508
|
+
// when the lease has none (a freshly-warmed lease always has it).
|
|
509
|
+
const envelope = lease.resumeState ?? (await getSandboxSessionEnvelope(db, workspaceId, session.id));
|
|
510
|
+
let established: EstablishedSandboxSession | undefined;
|
|
511
|
+
try {
|
|
512
|
+
// On a cold-restore (the lease's box is gone) this create() must carry the
|
|
513
|
+
// SAME stable run-env the turn declares, so a later turn finds no env delta.
|
|
514
|
+
const environment = await sessionAttachEnvironment(services, workspaceId, session);
|
|
515
|
+
established = input.establish
|
|
516
|
+
? await input.establish(envelope)
|
|
517
|
+
: await establishSandboxSessionFromEnvelope(settings, envelope, {
|
|
518
|
+
sessionId: session.id,
|
|
519
|
+
backendOverride: session.sandboxBackend,
|
|
520
|
+
environment,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Idempotent display stack (flock-guarded; a no-op when already up). A box
|
|
524
|
+
// that genuinely can't run the stack degrades to transport:null, not a throw.
|
|
525
|
+
try {
|
|
526
|
+
await ensureDisplayStack(established.session);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error instanceof DisplayStackUnsupportedError) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Resolve the provider tunnel + mint the scoped token, IN-PROCESS.
|
|
535
|
+
let exposed: Awaited<ReturnType<typeof exposeStreamPort>>;
|
|
536
|
+
try {
|
|
537
|
+
exposed = await exposeStreamPort(established.session, {
|
|
538
|
+
workspaceId,
|
|
539
|
+
sessionId: session.id,
|
|
540
|
+
viewerId,
|
|
541
|
+
leaseEpoch: lease.leaseEpoch,
|
|
542
|
+
streamTokenSecret: secret,
|
|
543
|
+
resolution: defaultResolution(settings),
|
|
544
|
+
});
|
|
545
|
+
} catch (error) {
|
|
546
|
+
// A transient/headless provider failure degrades the desktop cell.
|
|
547
|
+
if (error instanceof StreamPortUnavailableError) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Record the resolved URL on the lease under the epoch fence (rotation +
|
|
554
|
+
// disclosure). A fence miss (the box re-established under a newer epoch
|
|
555
|
+
// mid-call) is a no-op; we still return the cell we minted for our epoch.
|
|
556
|
+
await recordLeaseDataPlaneUrl(db, {
|
|
557
|
+
accountId,
|
|
558
|
+
workspaceId,
|
|
559
|
+
sandboxGroupId: session.sandboxGroupId,
|
|
560
|
+
expectedEpoch: lease.leaseEpoch,
|
|
561
|
+
dataPlaneUrl: exposed.url,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const mint: DesktopStreamMint = {
|
|
565
|
+
url: exposed.url,
|
|
566
|
+
token: exposed.token,
|
|
567
|
+
expiresAt: exposed.expiresAt,
|
|
568
|
+
resolution: exposed.resolution,
|
|
569
|
+
leaseEpoch: lease.leaseEpoch,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// ROLLOVER ROTATION (event-driven): when the live epoch advanced past what
|
|
573
|
+
// the caller last saw, the box was re-keyed → the OLD data-plane URL is
|
|
574
|
+
// stale. Emit stream.url.rotated so OTHER connected viewers hot-swap their
|
|
575
|
+
// noVNC socket. The requester already has the fresh cell as its response, so
|
|
576
|
+
// this is purely the out-of-band signal to the rest. Best-effort: a publish
|
|
577
|
+
// failure must never fail the mint.
|
|
578
|
+
if (bus && input.previousEpoch !== undefined && lease.leaseEpoch > input.previousEpoch) {
|
|
579
|
+
const payload: StreamUrlRotatedPayload = {
|
|
580
|
+
url: exposed.url,
|
|
581
|
+
token: exposed.token,
|
|
582
|
+
expiresAt: exposed.expiresAt,
|
|
583
|
+
leaseEpoch: lease.leaseEpoch,
|
|
584
|
+
transport: "vnc-ws",
|
|
585
|
+
viewerId,
|
|
586
|
+
};
|
|
587
|
+
try {
|
|
588
|
+
await appendAndPublishEvents(db, bus, workspaceId, session.id, [
|
|
589
|
+
{ type: "stream.url.rotated", payload },
|
|
590
|
+
]);
|
|
591
|
+
} catch {
|
|
592
|
+
// The durable SSE spine retries; a dropped publish here is not fatal.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return mint;
|
|
597
|
+
} catch {
|
|
598
|
+
// Any other failure (resume error, exec error) degrades the desktop cell to
|
|
599
|
+
// transport:null rather than failing the whole handshake — Channel-A still
|
|
600
|
+
// works. The capability resolver reports the desktop as available; the live
|
|
601
|
+
// URL is simply absent until the next op succeeds.
|
|
602
|
+
return null;
|
|
603
|
+
} finally {
|
|
604
|
+
await dropEstablishedHandle(established);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// P5.t — the REAL PTY terminal DATA PLANE, served API-DIRECT.
|
|
610
|
+
//
|
|
611
|
+
// mintTerminalStream is the EXACT terminal twin of mintDesktopStream: it resumes
|
|
612
|
+
// the WARM box BY ID in-process, idempotently ensures the ttyd PTY-over-websocket
|
|
613
|
+
// server (ensureTerminalServer), resolves the provider's scoped tunnel for port
|
|
614
|
+
// 7681 (a SEPARATE tunnel from the 6080 desktop noVNC → a different URL), mints
|
|
615
|
+
// the scoped per-viewer stream token, and records the resolved URL on the lease's
|
|
616
|
+
// terminal_data_plane_url column under the epoch fence. The fast-path re-mints
|
|
617
|
+
// ONLY a fresh token against the cached terminal URL (no box touch).
|
|
618
|
+
//
|
|
619
|
+
// It does NOT require the desktop to be on — it gates on the separate
|
|
620
|
+
// sandboxTerminalEnabled toggle. Degradation (no secret, headless backend, ttyd
|
|
621
|
+
// failure, provider tunnel failure) returns null → the Terminal cell falls back
|
|
622
|
+
// to the read-only sse-events firehose (a value, never a throw).
|
|
623
|
+
// ============================================================================
|
|
624
|
+
|
|
625
|
+
/** The minted terminal cell the handshake/attach folds into the Terminal
|
|
626
|
+
* capability (pty-ws). Null when degraded — the caller surfaces transport
|
|
627
|
+
* "sse-events" (the read-only firehose), never an exception. */
|
|
628
|
+
export type TerminalStreamMint = {
|
|
629
|
+
url: string;
|
|
630
|
+
token: string;
|
|
631
|
+
expiresAt: string;
|
|
632
|
+
leaseEpoch: number;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export type MintTerminalStreamInput = {
|
|
636
|
+
accountId: string;
|
|
637
|
+
workspaceId: string;
|
|
638
|
+
session: Session;
|
|
639
|
+
/** The viewer holder / principal id the scoped token is minted for. */
|
|
640
|
+
viewerId: string;
|
|
641
|
+
/** The live lease (must be warm/draining — the box is up). A selfhosted-active
|
|
642
|
+
* session may have no Modal group lease; omit and the selfhosted branch handles it. */
|
|
643
|
+
lease?: LeaseSnapshot;
|
|
644
|
+
/** Test seam: override how the box is re-established by id (see
|
|
645
|
+
* MintDesktopStreamInput.establish). Production NEVER passes this. */
|
|
646
|
+
establish?: (
|
|
647
|
+
envelope: Record<string, unknown> | null,
|
|
648
|
+
) => Promise<EstablishedSandboxSession>;
|
|
649
|
+
/** Test seam: inject a fake relay-resolving session for the selfhosted-active
|
|
650
|
+
* branch. Production NEVER passes this. */
|
|
651
|
+
resolveSelfhostedSession?: (sandbox: SandboxRecord) => Promise<{ resolveExposedPort?: (port: number) => Promise<unknown> }>;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Mint (or re-mint) the REAL PTY (ttyd pty-ws) terminal cell for a viewer against
|
|
656
|
+
* a WARM box, IN-PROCESS. Returns the minted cell, or null when the terminal tier
|
|
657
|
+
* degrades (terminal off, no resolvable stream-token secret, a headless backend,
|
|
658
|
+
* a ttyd-launch failure, or a provider-tunnel failure) — the caller surfaces the
|
|
659
|
+
* sse-events firehose, never an exception to the user. Mirrors mintDesktopStream.
|
|
660
|
+
*/
|
|
661
|
+
export async function mintTerminalStream(
|
|
662
|
+
services: ViewerServices,
|
|
663
|
+
input: MintTerminalStreamInput,
|
|
664
|
+
): Promise<TerminalStreamMint | null> {
|
|
665
|
+
const { db, settings } = services;
|
|
666
|
+
const { accountId, workspaceId, session } = input;
|
|
667
|
+
const lease = input.lease;
|
|
668
|
+
// Same caps-500 fix as the desktop mint: coerce a non-UUID principal id
|
|
669
|
+
// (grant.subjectId = "configured:key" for an API key) to a deterministic UUID
|
|
670
|
+
// so StreamTokenPayload.parse never throws an uncaught 500.
|
|
671
|
+
const viewerId = viewerIdAsUuid(input.viewerId);
|
|
672
|
+
|
|
673
|
+
// GATE 1: the terminal pty-ws plane requires the toggle ON, a real-PTY backend
|
|
674
|
+
// (desktop-capable images bake ttyd), and a resolvable stream-token secret.
|
|
675
|
+
if (!settings.sandboxTerminalEnabled) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
if (!desktopCapableBackend(session.sandboxBackend)) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
const secret = resolveStreamTokenSecret(settings);
|
|
682
|
+
if (!secret) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// SELFHOSTED ACTIVE: when the session's active sandbox is a selfhosted machine,
|
|
687
|
+
// route to the relay. NEVER fall through to the Modal group-box path (it would
|
|
688
|
+
// resume the wrong box / return a Modal URL).
|
|
689
|
+
if (session.activeSandboxId) {
|
|
690
|
+
const active = await getSandbox(db, workspaceId, session.activeSandboxId);
|
|
691
|
+
if (active?.kind === "selfhosted") {
|
|
692
|
+
return await tryMintActiveSelfhostedStream(services, { session, viewerId: input.viewerId, workspaceId, port: TERMINAL_STREAM_PORT, sandbox: active }, input.resolveSelfhostedSession);
|
|
693
|
+
}
|
|
694
|
+
// A Modal swap target (or unknown) falls through to the existing group-box path
|
|
695
|
+
// (unchanged — Modal swap-target streaming is out of scope for this fix).
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// GATE 2: the box must be live (the handshake never spins one up).
|
|
699
|
+
if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// FAST PATH: the terminal tunnel URL is stable for the life of the (epoch-fenced)
|
|
704
|
+
// box, so when the lease already caches it, mint ONLY a fresh scoped token (HMAC,
|
|
705
|
+
// sub-millisecond) against the cached URL — no box resume/exec at all. A rollover
|
|
706
|
+
// advances the epoch and clears terminalDataPlaneUrl (commitWarmingToWarm), so a
|
|
707
|
+
// cached URL here is always the current epoch's live ttyd tunnel.
|
|
708
|
+
if (lease.terminalDataPlaneUrl) {
|
|
709
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
710
|
+
const token = await mintStreamToken(secret, {
|
|
711
|
+
workspaceId,
|
|
712
|
+
sessionId: session.id,
|
|
713
|
+
viewerId,
|
|
714
|
+
leaseEpoch: lease.leaseEpoch,
|
|
715
|
+
port: TERMINAL_STREAM_PORT,
|
|
716
|
+
nowSeconds,
|
|
717
|
+
});
|
|
718
|
+
return {
|
|
719
|
+
url: lease.terminalDataPlaneUrl,
|
|
720
|
+
token,
|
|
721
|
+
expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
|
|
722
|
+
leaseEpoch: lease.leaseEpoch,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Resume the LIVE box by id (lease.resume_state authoritative), ensure ttyd, and
|
|
727
|
+
// resolve the 7681 tunnel + mint the scoped token, IN-PROCESS.
|
|
728
|
+
const envelope = lease.resumeState ?? (await getSandboxSessionEnvelope(db, workspaceId, session.id));
|
|
729
|
+
let established: EstablishedSandboxSession | undefined;
|
|
730
|
+
try {
|
|
731
|
+
// On a cold-restore this create() must carry the SAME stable run-env the turn
|
|
732
|
+
// declares, so a later turn finds no manifest-env delta.
|
|
733
|
+
const environment = await sessionAttachEnvironment(services, workspaceId, session);
|
|
734
|
+
established = input.establish
|
|
735
|
+
? await input.establish(envelope)
|
|
736
|
+
: await establishSandboxSessionFromEnvelope(settings, envelope, {
|
|
737
|
+
sessionId: session.id,
|
|
738
|
+
backendOverride: session.sandboxBackend,
|
|
739
|
+
environment,
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// Idempotent ttyd launch (flock-guarded; a no-op when already up). A box that
|
|
743
|
+
// genuinely can't run it degrades to the sse-events firehose, not a throw.
|
|
744
|
+
try {
|
|
745
|
+
await ensureTerminalServer(established.session, { port: TERMINAL_STREAM_PORT });
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error instanceof TerminalServerUnsupportedError) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Resolve the provider tunnel for 7681 + mint the scoped token, IN-PROCESS.
|
|
754
|
+
// exposeStreamPort is port-agnostic; it returns transport "vnc-ws"/client
|
|
755
|
+
// "novnc" tags we ignore for the terminal (the contract carries pty-ws) — we
|
|
756
|
+
// use only its url/token/expiresAt.
|
|
757
|
+
let exposed: Awaited<ReturnType<typeof exposeStreamPort>>;
|
|
758
|
+
try {
|
|
759
|
+
exposed = await exposeStreamPort(established.session, {
|
|
760
|
+
workspaceId,
|
|
761
|
+
sessionId: session.id,
|
|
762
|
+
viewerId,
|
|
763
|
+
leaseEpoch: lease.leaseEpoch,
|
|
764
|
+
streamTokenSecret: secret,
|
|
765
|
+
port: TERMINAL_STREAM_PORT,
|
|
766
|
+
});
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (error instanceof StreamPortUnavailableError) {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Record the resolved terminal URL on the lease under the epoch fence. A fence
|
|
775
|
+
// miss (box re-established under a newer epoch mid-call) is a no-op; we still
|
|
776
|
+
// return the cell we minted for our epoch.
|
|
777
|
+
await recordLeaseTerminalDataPlaneUrl(db, {
|
|
778
|
+
accountId,
|
|
779
|
+
workspaceId,
|
|
780
|
+
sandboxGroupId: session.sandboxGroupId,
|
|
781
|
+
expectedEpoch: lease.leaseEpoch,
|
|
782
|
+
terminalDataPlaneUrl: exposed.url,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
url: exposed.url,
|
|
787
|
+
token: exposed.token,
|
|
788
|
+
expiresAt: exposed.expiresAt,
|
|
789
|
+
leaseEpoch: lease.leaseEpoch,
|
|
790
|
+
};
|
|
791
|
+
} catch {
|
|
792
|
+
// Any other failure degrades the terminal pty-ws cell to the sse-events
|
|
793
|
+
// firehose rather than failing the whole handshake.
|
|
794
|
+
return null;
|
|
795
|
+
} finally {
|
|
796
|
+
await dropEstablishedHandle(established);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ============================================================================
|
|
801
|
+
// M8b — the SELFHOSTED relay stream cell.
|
|
802
|
+
//
|
|
803
|
+
// When the session's ACTIVE sandbox is a selfhosted machine (a swap target, or the
|
|
804
|
+
// session's own selfhosted group box), the desktop/terminal stream does NOT ride a
|
|
805
|
+
// Modal provider tunnel — it rides the `opengeni-relay` edge. The selfhosted
|
|
806
|
+
// session's `resolveExposedPort(port)` returns the relay URL SHAPE (host/port/tls/
|
|
807
|
+
// path + the `ws=&agent=&port=&channel=` routing query), and exposeStreamPort mints
|
|
808
|
+
// the scoped `ogs_` token. The CRITICAL M8b seam: the token is fenced by the swap
|
|
809
|
+
// `active_epoch` (NOT the Modal lease epoch), so the relay's stale-viewer fence
|
|
810
|
+
// rejects a viewer whose token predates a swap-away — it cannot reach a machine the
|
|
811
|
+
// session swapped off of. control ops are already active-epoch-fenced (the routing
|
|
812
|
+
// proxy); this closes the STREAM side.
|
|
813
|
+
// ============================================================================
|
|
814
|
+
|
|
815
|
+
// Build a ControlRpc backed by the NATS events bus (mirrors fleet.ts:controlRpc).
|
|
816
|
+
function controlRpc(bus: EventBus | undefined): ControlRpc {
|
|
817
|
+
return new NatsControlRpc(async (): Promise<NatsRequestConnection | null> => {
|
|
818
|
+
if (!bus) {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
return bus.getRequestConnection();
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Mint the relay stream cell against the session's ACTIVE selfhosted machine,
|
|
827
|
+
* fenced by active_epoch. Returns null (degrade, never throw) when the active
|
|
828
|
+
* sandbox is not selfhosted, the agent is offline, or the relay channel can't be
|
|
829
|
+
* ensured. `sandbox` is passed in already-fetched to avoid a duplicate getSandbox.
|
|
830
|
+
*/
|
|
831
|
+
async function tryMintActiveSelfhostedStream(
|
|
832
|
+
services: ViewerServices,
|
|
833
|
+
input: { session: Session; viewerId: string; workspaceId: string; port: number; sandbox: SandboxRecord },
|
|
834
|
+
// optional test seam (mirrors the existing `establish?` seam pattern): inject a
|
|
835
|
+
// fake relay-resolving session; production NEVER passes it.
|
|
836
|
+
resolveSelfhostedSession?: (sandbox: SandboxRecord) => Promise<{ resolveExposedPort?: (port: number) => Promise<unknown> }>,
|
|
837
|
+
): Promise<TerminalStreamMint | null> {
|
|
838
|
+
const { settings, bus } = services;
|
|
839
|
+
const { session, workspaceId, port, sandbox } = input;
|
|
840
|
+
if (!sandbox.enrollmentId) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
// The relay needs NATS; degrade to null without a bus.
|
|
844
|
+
if (!bus && !resolveSelfhostedSession) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
let shSession: { resolveExposedPort?: (port: number) => Promise<unknown> };
|
|
848
|
+
try {
|
|
849
|
+
if (resolveSelfhostedSession) {
|
|
850
|
+
shSession = await resolveSelfhostedSession(sandbox);
|
|
851
|
+
} else {
|
|
852
|
+
const client = new SelfhostedSandboxClient({
|
|
853
|
+
workspaceId,
|
|
854
|
+
relay: relayConfigFromSettings(settings),
|
|
855
|
+
controlRpcFactory: () => controlRpc(bus),
|
|
856
|
+
agentId: sandbox.enrollmentId,
|
|
857
|
+
epoch: session.activeEpoch,
|
|
858
|
+
});
|
|
859
|
+
shSession = await client.resume({ agentId: sandbox.enrollmentId });
|
|
860
|
+
}
|
|
861
|
+
} catch (error) {
|
|
862
|
+
console.warn(
|
|
863
|
+
`[tryMintActiveSelfhostedStream] resume failed for agent=${sandbox.enrollmentId} ` +
|
|
864
|
+
`port=${input.port} epoch=${session.activeEpoch}: ` +
|
|
865
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
866
|
+
);
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
return mintSelfhostedStream(services, {
|
|
870
|
+
workspaceId,
|
|
871
|
+
sessionId: session.id,
|
|
872
|
+
viewerId: input.viewerId,
|
|
873
|
+
activeEpoch: session.activeEpoch,
|
|
874
|
+
port,
|
|
875
|
+
session: shSession,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** The structural slice of a selfhosted session the relay stream mint needs. */
|
|
880
|
+
type RelayResolvableSession = {
|
|
881
|
+
resolveExposedPort?: (port: number) => Promise<unknown>;
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
export type MintSelfhostedStreamInput = {
|
|
885
|
+
workspaceId: string;
|
|
886
|
+
sessionId: string;
|
|
887
|
+
/** The viewer holder / principal id the scoped token is minted for. */
|
|
888
|
+
viewerId: string;
|
|
889
|
+
/** The swap fence: the session's `active_epoch`. The minted `ogs_` token carries
|
|
890
|
+
* THIS as its leaseEpoch claim so the relay rejects a stale-epoch (swapped-away)
|
|
891
|
+
* viewer. */
|
|
892
|
+
activeEpoch: number;
|
|
893
|
+
/** The exposed stream port (6080 desktop / 7681 terminal). */
|
|
894
|
+
port: number;
|
|
895
|
+
/** The resolvable selfhosted session (the routing proxy resolves the active
|
|
896
|
+
* selfhosted backend; its `resolveExposedPort` returns the relay endpoint). */
|
|
897
|
+
session: RelayResolvableSession;
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Mint the selfhosted relay stream cell for a viewer against the session's ACTIVE
|
|
902
|
+
* selfhosted machine, IN-PROCESS. Resolves the relay endpoint via the selfhosted
|
|
903
|
+
* session's `resolveExposedPort` and mints the scoped `ogs_` token FENCED BY THE
|
|
904
|
+
* SWAP `active_epoch`. Returns null when the stream tier degrades (no stream-token
|
|
905
|
+
* secret, the agent is offline / cannot ensure a channel) — the caller surfaces
|
|
906
|
+
* transport:null, never an exception.
|
|
907
|
+
*
|
|
908
|
+
* The token is RECORDED against the viewer holder by the caller and is NEVER a URL
|
|
909
|
+
* query param (the relay validates the in-band token); the relay's stale-viewer
|
|
910
|
+
* fence uses the token's leaseEpoch claim (== activeEpoch here).
|
|
911
|
+
*/
|
|
912
|
+
export async function mintSelfhostedStream(
|
|
913
|
+
services: ViewerServices,
|
|
914
|
+
input: MintSelfhostedStreamInput,
|
|
915
|
+
): Promise<TerminalStreamMint | null> {
|
|
916
|
+
const { settings } = services;
|
|
917
|
+
const secret = resolveStreamTokenSecret(settings);
|
|
918
|
+
if (!secret) {
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
const viewerId = viewerIdAsUuid(input.viewerId);
|
|
922
|
+
if (typeof input.session?.resolveExposedPort !== "function") {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
// exposeStreamPort threads the epoch we pass into the `ogs_` token's leaseEpoch
|
|
927
|
+
// claim. For selfhosted we pass the swap active_epoch — THE fence the relay
|
|
928
|
+
// enforces so a swapped-away viewer is rejected.
|
|
929
|
+
const exposed = await exposeStreamPort(input.session, {
|
|
930
|
+
workspaceId: input.workspaceId,
|
|
931
|
+
sessionId: input.sessionId,
|
|
932
|
+
viewerId,
|
|
933
|
+
leaseEpoch: input.activeEpoch,
|
|
934
|
+
streamTokenSecret: secret,
|
|
935
|
+
port: input.port,
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
url: exposed.url,
|
|
939
|
+
token: exposed.token,
|
|
940
|
+
expiresAt: exposed.expiresAt,
|
|
941
|
+
leaseEpoch: input.activeEpoch,
|
|
942
|
+
};
|
|
943
|
+
} catch (error) {
|
|
944
|
+
// A headless / offline / channel-ensure failure degrades the cell to
|
|
945
|
+
// transport:null rather than throwing (mirrors the Modal mint paths). The
|
|
946
|
+
// mint degrades SILENTLY to the client, so log WHY here — otherwise a relay
|
|
947
|
+
// ensure failure (agent display probe, producer dial) is invisible.
|
|
948
|
+
console.warn(
|
|
949
|
+
`[mintSelfhostedStream] relay stream mint degraded to transport:null ` +
|
|
950
|
+
`(session=${input.sessionId} port=${input.port} epoch=${input.activeEpoch}): ` +
|
|
951
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
952
|
+
);
|
|
953
|
+
if (error instanceof StreamPortUnavailableError) {
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// The framebuffer geometry from settings (streamResolutionWidth/Height; default
|
|
961
|
+
// 1280x800, the spike's proven geometry).
|
|
962
|
+
function defaultResolution(settings: Settings): [number, number] {
|
|
963
|
+
return [settings.streamResolutionWidth, settings.streamResolutionHeight];
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Coerce a viewer/principal id into a valid UUID for the scoped stream-token
|
|
970
|
+
* payload (StreamTokenPayload.viewerId is z.string().uuid()).
|
|
971
|
+
*
|
|
972
|
+
* The GET stream-capabilities handshake mints a token scoped to the CALLING
|
|
973
|
+
* PRINCIPAL — grant.subjectId — which for an API-key principal is a NON-UUID like
|
|
974
|
+
* "configured:key". Passing that straight to mintStreamToken threw a ZodError in
|
|
975
|
+
* StreamTokenPayload.parse, which escaped as an uncaught 500 (caps-500 bug). The
|
|
976
|
+
* browser's managed-session subject IS a UUID and is returned unchanged, so it is
|
|
977
|
+
* unaffected. A non-UUID principal is mapped to a DETERMINISTIC v5-shaped UUID
|
|
978
|
+
* (SHA-256 of the raw id, RFC-4122 version/variant bits set) so the same
|
|
979
|
+
* principal always mints the same viewerId (stable scoping; idempotent re-mint).
|
|
980
|
+
*/
|
|
981
|
+
function viewerIdAsUuid(rawViewerId: string): string {
|
|
982
|
+
if (UUID_RE.test(rawViewerId)) {
|
|
983
|
+
return rawViewerId;
|
|
984
|
+
}
|
|
985
|
+
const hex = createHash("sha256").update(`opengeni:stream-viewer:${rawViewerId}`).digest("hex");
|
|
986
|
+
// Shape the first 16 bytes as a version-5 UUID (deterministic, name-based).
|
|
987
|
+
const b = hex.slice(0, 32).split("");
|
|
988
|
+
b[12] = "5"; // version 5
|
|
989
|
+
const variantNibble = (parseInt(b[16]!, 16) & 0x3) | 0x8; // RFC-4122 variant (8-b)
|
|
990
|
+
b[16] = variantNibble.toString(16);
|
|
991
|
+
const s = b.join("");
|
|
992
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
|
|
993
|
+
}
|