@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.
Files changed (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. 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
+ }