@opengeni/api-router 0.7.3 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/src/sandbox/viewer.ts
CHANGED
|
@@ -31,12 +31,11 @@ import { githubAppBotIdentity } from "@opengeni/github";
|
|
|
31
31
|
import { type Session, type StreamUrlRotatedPayload } from "@opengeni/contracts";
|
|
32
32
|
import {
|
|
33
33
|
acquireLease,
|
|
34
|
-
commitWarmingToWarm,
|
|
35
|
-
failWarmingToCold,
|
|
36
34
|
getSandbox,
|
|
37
35
|
getSandboxSessionEnvelope,
|
|
38
36
|
heartbeatLeaseHolder,
|
|
39
37
|
loadWorkspaceEnvironmentForRun,
|
|
38
|
+
markSandboxProviderReady,
|
|
40
39
|
markWarmLeaseInstanceLost,
|
|
41
40
|
readLease,
|
|
42
41
|
recordLeaseDataPlaneUrl,
|
|
@@ -58,23 +57,21 @@ import {
|
|
|
58
57
|
ensureTerminalServer,
|
|
59
58
|
establishSandboxSessionFromEnvelope,
|
|
60
59
|
isProviderSandboxNotFoundError,
|
|
61
|
-
SandboxResumeStateUnavailableError,
|
|
62
60
|
exposeStreamPort,
|
|
63
61
|
desktopCapableBackend,
|
|
64
62
|
NatsControlRpc,
|
|
65
63
|
SelfhostedSandboxClient,
|
|
66
|
-
serializeEstablishedSandboxEnvelope,
|
|
67
|
-
mintStreamToken,
|
|
68
|
-
STREAM_TOKEN_DEFAULT_TTL_SECONDS,
|
|
69
64
|
TERMINAL_STREAM_PORT,
|
|
70
65
|
DisplayStackUnsupportedError,
|
|
71
66
|
TerminalServerUnsupportedError,
|
|
67
|
+
verifySandboxExecReadiness,
|
|
72
68
|
StreamPortUnavailableError,
|
|
73
69
|
type ControlRpc,
|
|
74
70
|
type EstablishedSandboxSession,
|
|
75
71
|
type NatsRequestConnection,
|
|
76
72
|
} from "@opengeni/runtime/sandbox";
|
|
77
73
|
import { relayConfigFromSettings } from "@opengeni/core";
|
|
74
|
+
import { establishApiSandboxSpawner } from "./rematerialize";
|
|
78
75
|
|
|
79
76
|
/** The minimal services a viewer op needs: the DB + settings (lease cadence +
|
|
80
77
|
* the sandbox client construction the leaf reads from settings). The bus is
|
|
@@ -84,6 +81,10 @@ export type ViewerServices = {
|
|
|
84
81
|
db: Database;
|
|
85
82
|
settings: Settings;
|
|
86
83
|
bus?: EventBus;
|
|
84
|
+
/** Provider-establish dependency used by API-direct readiness and stream
|
|
85
|
+
* operations. Production uses the runtime leaf; isolated tests may supply a
|
|
86
|
+
* deterministic provider without replacing a process-global module. */
|
|
87
|
+
establishSandboxSession?: typeof establishSandboxSessionFromEnvelope;
|
|
87
88
|
};
|
|
88
89
|
|
|
89
90
|
/** A coherent snapshot the routes echo back: the holder id (the viewer's fence-
|
|
@@ -93,6 +94,9 @@ export type ViewerAttachResult = {
|
|
|
93
94
|
viewerId: string;
|
|
94
95
|
liveness: LeaseSnapshot["liveness"];
|
|
95
96
|
leaseEpoch: number;
|
|
97
|
+
workspaceGeneration: number | null;
|
|
98
|
+
archiveGeneration: number | null;
|
|
99
|
+
archiveComplete: boolean;
|
|
96
100
|
sandboxGroupId: string;
|
|
97
101
|
// The viewer heartbeat cadence the client must beat at to keep the holder
|
|
98
102
|
// alive (shorter than the viewer-holder TTL the reaper enforces).
|
|
@@ -206,6 +210,12 @@ export async function attachViewer(
|
|
|
206
210
|
|
|
207
211
|
// FENCED: a newer epoch re-established the box. Release our just-registered
|
|
208
212
|
// holder and surface a 409 — the client re-reads capabilities and re-attaches.
|
|
213
|
+
if (acquired.role === "blocked") {
|
|
214
|
+
await release();
|
|
215
|
+
throw new HTTPException(409, {
|
|
216
|
+
message: `sandbox recovery ${acquired.lease.recovery.restore.status} at epoch ${acquired.lease.leaseEpoch}`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
209
219
|
if (acquired.role === "fenced") {
|
|
210
220
|
await release();
|
|
211
221
|
throw new HTTPException(409, {
|
|
@@ -235,46 +245,31 @@ export async function attachViewer(
|
|
|
235
245
|
// fresh box and replays the archive via hydrateWorkspace, so /workspace
|
|
236
246
|
// survives the box churn (sandbox-file-persistence). No archive -> the bare
|
|
237
247
|
// session envelope (a never-warmed cold start).
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
recovery: "create-or-restore",
|
|
242
|
-
backendOverride: session.sandboxBackend,
|
|
243
|
-
environment,
|
|
244
|
-
});
|
|
245
|
-
// Fold the LIVE box into a re-resumable envelope and persist it as the
|
|
246
|
-
// lease's resume_state, so EVERY later op (another viewer, a Channel-A
|
|
247
|
-
// call, the reaper) resumes THIS box by id instead of cold-creating a
|
|
248
|
-
// rival. Fall back to the session envelope only when serialize is
|
|
249
|
-
// unavailable. (Without this the box churned: each op spawned its own box.)
|
|
250
|
-
const resumeEnvelope =
|
|
251
|
-
(await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
|
|
252
|
-
const committed = await commitWarmingToWarm(db, {
|
|
248
|
+
const result = await establishApiSandboxSpawner({
|
|
249
|
+
db,
|
|
250
|
+
settings,
|
|
253
251
|
accountId,
|
|
254
252
|
workspaceId,
|
|
255
253
|
sandboxGroupId,
|
|
254
|
+
sessionId: session.id,
|
|
255
|
+
backend: session.sandboxBackend,
|
|
256
|
+
environment,
|
|
256
257
|
expectedEpoch,
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
acquiredLease: acquired.lease,
|
|
259
|
+
fallbackEnvelope: envelope,
|
|
259
260
|
dataPlaneUrl: null,
|
|
260
|
-
resumeBackendId: established.backendId,
|
|
261
|
-
resumeState: resumeEnvelope,
|
|
262
|
-
leaseTtlMs,
|
|
263
261
|
});
|
|
264
|
-
|
|
265
|
-
// A reaper reset our warming row (we were too slow) or a sibling
|
|
266
|
-
// re-established and bumped the epoch. Release our holder and surface a
|
|
267
|
-
// 409. NEVER provider-delete the box (it rides the provider idle-timeout).
|
|
268
|
-
await release();
|
|
269
|
-
throw new SandboxLeaseSupersededError(sandboxGroupId, expectedEpoch);
|
|
270
|
-
}
|
|
262
|
+
established = result.established;
|
|
271
263
|
return {
|
|
272
264
|
viewerId,
|
|
273
|
-
liveness:
|
|
274
|
-
leaseEpoch:
|
|
265
|
+
liveness: result.lease.liveness,
|
|
266
|
+
leaseEpoch: result.lease.leaseEpoch,
|
|
267
|
+
workspaceGeneration: result.lease.workspaceGeneration,
|
|
268
|
+
archiveGeneration: result.lease.archiveGeneration,
|
|
269
|
+
archiveComplete: result.lease.archiveComplete,
|
|
275
270
|
sandboxGroupId,
|
|
276
271
|
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
277
|
-
dataPlaneUrl:
|
|
272
|
+
dataPlaneUrl: result.lease.dataPlaneUrl,
|
|
278
273
|
};
|
|
279
274
|
} catch (error) {
|
|
280
275
|
if (error instanceof SandboxLeaseSupersededError) {
|
|
@@ -282,11 +277,8 @@ export async function attachViewer(
|
|
|
282
277
|
message: `sandbox lease superseded (epoch ${error.leaseEpoch}); re-read capabilities and re-attach`,
|
|
283
278
|
});
|
|
284
279
|
}
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// are intentionally kept by failWarmingToCold for the re-acquire; then
|
|
288
|
-
// release our own holder so we don't pin a cold lease.
|
|
289
|
-
await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
|
|
280
|
+
// The shared API spawner helper already terminated any unpublished box
|
|
281
|
+
// and durably recorded the epoch-fenced failure state.
|
|
290
282
|
await release();
|
|
291
283
|
// Mirror the Channel-A spawner (channel-a.ts): a provider/config failure to
|
|
292
284
|
// bring up the cold box is a client-actionable 409 ("sandbox not available;
|
|
@@ -305,17 +297,134 @@ export async function attachViewer(
|
|
|
305
297
|
}
|
|
306
298
|
}
|
|
307
299
|
|
|
308
|
-
// ATTACHED / REARMED:
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
300
|
+
// ATTACHED / REARMED: a lease row is not provider/readiness evidence. Wait for
|
|
301
|
+
// a sibling spawner if necessary, then resume the exact instance and pass the
|
|
302
|
+
// same bounded command probe used by every create path. A provider NotFound
|
|
303
|
+
// retires this exact epoch and returns 409; it never becomes a false-success
|
|
304
|
+
// viewer attachment.
|
|
305
|
+
try {
|
|
306
|
+
const deadline = Date.now() + settings.sandboxWarmingTimeoutMs;
|
|
307
|
+
let live = acquired.lease;
|
|
308
|
+
while (live.liveness === "warming" && Date.now() < deadline) {
|
|
309
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
310
|
+
const refreshed = await readLease(db, workspaceId, sandboxGroupId);
|
|
311
|
+
if (!refreshed) break;
|
|
312
|
+
live = refreshed;
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
live.liveness !== "warm" ||
|
|
316
|
+
live.instanceId === null ||
|
|
317
|
+
live.recovery.workspace.status === "degraded" ||
|
|
318
|
+
live.recovery.workspace.status === "unrecoverable"
|
|
319
|
+
) {
|
|
320
|
+
throw new HTTPException(409, {
|
|
321
|
+
message: `sandbox is ${live.recovery.restore.status} at epoch ${live.leaseEpoch}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const environment = await sessionAttachEnvironment(services, workspaceId, session);
|
|
325
|
+
let observed: EstablishedSandboxSession | undefined;
|
|
326
|
+
try {
|
|
327
|
+
const establish = services.establishSandboxSession ?? establishSandboxSessionFromEnvelope;
|
|
328
|
+
observed = await establish(settings, live.resumeState, {
|
|
329
|
+
sessionId: session.id,
|
|
330
|
+
recovery: "resume-only",
|
|
331
|
+
backendOverride: session.sandboxBackend,
|
|
332
|
+
environment,
|
|
333
|
+
});
|
|
334
|
+
await verifySandboxExecReadiness(observed);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (
|
|
337
|
+
await retireMissingWarmLease(
|
|
338
|
+
services,
|
|
339
|
+
{ accountId, workspaceId, session, lease: live },
|
|
340
|
+
error,
|
|
341
|
+
)
|
|
342
|
+
) {
|
|
343
|
+
throw new HTTPException(409, {
|
|
344
|
+
message: `sandbox instance was lost; retry to restore it`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
throw error;
|
|
348
|
+
} finally {
|
|
349
|
+
await dropEstablishedHandle(observed);
|
|
350
|
+
}
|
|
351
|
+
const ready = await markSandboxProviderReady(db, {
|
|
352
|
+
accountId,
|
|
353
|
+
workspaceId,
|
|
354
|
+
sandboxGroupId,
|
|
355
|
+
expectedEpoch: live.leaseEpoch,
|
|
356
|
+
expectedInstanceId: live.instanceId,
|
|
357
|
+
});
|
|
358
|
+
if (!ready.wrote || !ready.lease) {
|
|
359
|
+
throw new HTTPException(409, {
|
|
360
|
+
message: `sandbox lease superseded during readiness verification; retry`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
viewerId,
|
|
365
|
+
liveness: ready.lease.liveness,
|
|
366
|
+
leaseEpoch: ready.lease.leaseEpoch,
|
|
367
|
+
workspaceGeneration: ready.lease.workspaceGeneration,
|
|
368
|
+
archiveGeneration: ready.lease.archiveGeneration,
|
|
369
|
+
archiveComplete: ready.lease.archiveComplete,
|
|
370
|
+
sandboxGroupId,
|
|
371
|
+
viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
|
|
372
|
+
dataPlaneUrl: ready.lease.dataPlaneUrl,
|
|
373
|
+
};
|
|
374
|
+
} catch (error) {
|
|
375
|
+
await release();
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export type SessionGroupReadinessHold = {
|
|
381
|
+
lease: LeaseSnapshot;
|
|
382
|
+
/** Idempotently release the viewer holder after route publication settles. */
|
|
383
|
+
release: () => Promise<void>;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/** Readiness callback for fleet attach/swap. It acquires one disposable viewer
|
|
387
|
+
* holder through the same provider-verifying path as the UI and RETURNS that
|
|
388
|
+
* holder to the swap owner. The holder must remain live until route publication
|
|
389
|
+
* settles, otherwise the reaper can drain the just-verified target between the
|
|
390
|
+
* readiness probe and the route CAS. A first 409 may have fenced a missing warm
|
|
391
|
+
* provider; one bounded retry lets the normal cold->warming election rematerialize
|
|
392
|
+
* it. */
|
|
393
|
+
export async function ensureSessionGroupReady(
|
|
394
|
+
services: ViewerServices,
|
|
395
|
+
input: { accountId: string; workspaceId: string; session: Session },
|
|
396
|
+
): Promise<SessionGroupReadinessHold> {
|
|
397
|
+
let lastError: unknown;
|
|
398
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
399
|
+
let release: (() => Promise<void>) | undefined;
|
|
400
|
+
try {
|
|
401
|
+
const attached = await attachViewer(services, input);
|
|
402
|
+
let releasePromise: Promise<void> | undefined;
|
|
403
|
+
release = () =>
|
|
404
|
+
(releasePromise ??= detachViewer(services, {
|
|
405
|
+
accountId: input.accountId,
|
|
406
|
+
workspaceId: input.workspaceId,
|
|
407
|
+
sandboxGroupId: attached.sandboxGroupId,
|
|
408
|
+
viewerId: attached.viewerId,
|
|
409
|
+
}).then(() => undefined));
|
|
410
|
+
const lease = await readLease(services.db, input.workspaceId, attached.sandboxGroupId);
|
|
411
|
+
if (
|
|
412
|
+
lease?.liveness === "warm" &&
|
|
413
|
+
lease.recovery.provider.status === "exists" &&
|
|
414
|
+
lease.recovery.workspace.status === "ready"
|
|
415
|
+
) {
|
|
416
|
+
return { lease, release };
|
|
417
|
+
}
|
|
418
|
+
lastError = new Error("sandbox did not reach verified workspace readiness");
|
|
419
|
+
} catch (error) {
|
|
420
|
+
lastError = error;
|
|
421
|
+
if (release) await release().catch(() => undefined);
|
|
422
|
+
if (!release && (!(error instanceof HTTPException) || error.status !== 409)) break;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (release) await release().catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
throw lastError ?? new Error("sandbox readiness could not be established");
|
|
319
428
|
}
|
|
320
429
|
|
|
321
430
|
/**
|
|
@@ -414,8 +523,7 @@ async function retireMissingWarmLease(
|
|
|
414
523
|
): Promise<boolean> {
|
|
415
524
|
if (
|
|
416
525
|
input.lease.instanceId === null ||
|
|
417
|
-
|
|
418
|
-
!isProviderSandboxNotFoundError(input.session.sandboxBackend, error))
|
|
526
|
+
!isProviderSandboxNotFoundError(input.session.sandboxBackend, error)
|
|
419
527
|
) {
|
|
420
528
|
return false;
|
|
421
529
|
}
|
|
@@ -528,8 +636,8 @@ export type MintDesktopStreamInput = {
|
|
|
528
636
|
* Idempotent display-stack + resolveExposedPort are safe to call N times. The
|
|
529
637
|
* resolved URL is recorded on the lease (data_plane_url) under the epoch fence; a
|
|
530
638
|
* stale-epoch write (the box re-established under a newer epoch mid-call) is a
|
|
531
|
-
* no-op and
|
|
532
|
-
*
|
|
639
|
+
* no-op and the mint returns null rather than disclosing a capability for the
|
|
640
|
+
* superseded provider epoch.
|
|
533
641
|
*/
|
|
534
642
|
export async function mintDesktopStream(
|
|
535
643
|
services: ViewerServices,
|
|
@@ -595,33 +703,6 @@ export async function mintDesktopStream(
|
|
|
595
703
|
return null;
|
|
596
704
|
}
|
|
597
705
|
|
|
598
|
-
// FAST PATH (P4.2 perf): when the lease already holds the data-plane URL for
|
|
599
|
-
// this epoch, the box is warm, exposed, and the display stack is already up.
|
|
600
|
-
// Re-resuming the box by id (Modal resume-by-id is ~40s) + re-running
|
|
601
|
-
// ensureDisplayStack + exposeStreamPort on EVERY stream-capabilities poll made
|
|
602
|
-
// the desktop look like it was "starting" forever. The tunnel URL is stable for
|
|
603
|
-
// the life of the (epoch-fenced) box, so mint ONLY a fresh scoped token (HMAC,
|
|
604
|
-
// sub-millisecond) against the cached URL and return — no box touch at all. A
|
|
605
|
-
// rollover advances the epoch and re-records dataPlaneUrl via the slow path, so
|
|
606
|
-
// a cached URL here is always the current epoch's live tunnel.
|
|
607
|
-
if (lease.dataPlaneUrl) {
|
|
608
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
609
|
-
const token = await mintStreamToken(secret, {
|
|
610
|
-
workspaceId,
|
|
611
|
-
sessionId: session.id,
|
|
612
|
-
viewerId,
|
|
613
|
-
leaseEpoch: lease.leaseEpoch,
|
|
614
|
-
nowSeconds,
|
|
615
|
-
});
|
|
616
|
-
return {
|
|
617
|
-
url: lease.dataPlaneUrl,
|
|
618
|
-
token,
|
|
619
|
-
expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
|
|
620
|
-
resolution: defaultResolution(settings),
|
|
621
|
-
leaseEpoch: lease.leaseEpoch,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
|
|
625
706
|
// Resume the LIVE box by id from the authoritative lease descriptor. An
|
|
626
707
|
// attached stream resolver has no creation authority and never substitutes a
|
|
627
708
|
// per-session envelope for missing lease state.
|
|
@@ -634,12 +715,16 @@ export async function mintDesktopStream(
|
|
|
634
715
|
try {
|
|
635
716
|
established = input.establish
|
|
636
717
|
? await input.establish(envelope)
|
|
637
|
-
: await
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
718
|
+
: await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
|
|
719
|
+
settings,
|
|
720
|
+
envelope,
|
|
721
|
+
{
|
|
722
|
+
sessionId: session.id,
|
|
723
|
+
recovery: "resume-only",
|
|
724
|
+
backendOverride: session.sandboxBackend,
|
|
725
|
+
environment,
|
|
726
|
+
},
|
|
727
|
+
);
|
|
643
728
|
} catch (error) {
|
|
644
729
|
await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
|
|
645
730
|
return null;
|
|
@@ -648,7 +733,13 @@ export async function mintDesktopStream(
|
|
|
648
733
|
// Idempotent display stack (flock-guarded; a no-op when already up). A box
|
|
649
734
|
// that genuinely can't run the stack degrades to transport:null, not a throw.
|
|
650
735
|
try {
|
|
651
|
-
await ensureDisplayStack(established.session
|
|
736
|
+
await ensureDisplayStack(established.session, {
|
|
737
|
+
telemetryContext: {
|
|
738
|
+
callerKind: "viewer",
|
|
739
|
+
...(lease.instanceId ? { sandboxId: lease.instanceId } : {}),
|
|
740
|
+
leaseEpoch: lease.leaseEpoch,
|
|
741
|
+
},
|
|
742
|
+
});
|
|
652
743
|
} catch (error) {
|
|
653
744
|
if (error instanceof DisplayStackUnsupportedError) {
|
|
654
745
|
return null;
|
|
@@ -676,15 +767,18 @@ export async function mintDesktopStream(
|
|
|
676
767
|
}
|
|
677
768
|
|
|
678
769
|
// Record the resolved URL on the lease under the epoch fence (rotation +
|
|
679
|
-
// disclosure). A fence miss
|
|
680
|
-
//
|
|
681
|
-
await recordLeaseDataPlaneUrl(db, {
|
|
770
|
+
// disclosure). A fence miss means this capability belongs to a stale
|
|
771
|
+
// provider epoch; never return it to the caller.
|
|
772
|
+
const recorded = await recordLeaseDataPlaneUrl(db, {
|
|
682
773
|
accountId,
|
|
683
774
|
workspaceId,
|
|
684
775
|
sandboxGroupId: session.sandboxGroupId,
|
|
685
776
|
expectedEpoch: lease.leaseEpoch,
|
|
686
777
|
dataPlaneUrl: exposed.url,
|
|
687
778
|
});
|
|
779
|
+
if (!recorded) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
688
782
|
|
|
689
783
|
const mint: DesktopStreamMint = {
|
|
690
784
|
url: exposed.url,
|
|
@@ -738,8 +832,8 @@ export async function mintDesktopStream(
|
|
|
738
832
|
// server (ensureTerminalServer), resolves the provider's scoped tunnel for port
|
|
739
833
|
// 7681 (a SEPARATE tunnel from the 6080 desktop noVNC → a different URL), mints
|
|
740
834
|
// the scoped per-viewer stream token, and records the resolved URL on the lease's
|
|
741
|
-
// terminal_data_plane_url column under the epoch fence.
|
|
742
|
-
//
|
|
835
|
+
// terminal_data_plane_url column under the epoch fence. Every mint re-establishes
|
|
836
|
+
// provider/readiness state; the cached URL is never used as a blind fast path.
|
|
743
837
|
//
|
|
744
838
|
// It does NOT require the desktop to be on — it gates on the separate
|
|
745
839
|
// sandboxTerminalEnabled toggle. Degradation (no secret, headless backend, ttyd
|
|
@@ -835,29 +929,6 @@ export async function mintTerminalStream(
|
|
|
835
929
|
return null;
|
|
836
930
|
}
|
|
837
931
|
|
|
838
|
-
// FAST PATH: the terminal tunnel URL is stable for the life of the (epoch-fenced)
|
|
839
|
-
// box, so when the lease already caches it, mint ONLY a fresh scoped token (HMAC,
|
|
840
|
-
// sub-millisecond) against the cached URL — no box resume/exec at all. A rollover
|
|
841
|
-
// advances the epoch and clears terminalDataPlaneUrl (commitWarmingToWarm), so a
|
|
842
|
-
// cached URL here is always the current epoch's live ttyd tunnel.
|
|
843
|
-
if (lease.terminalDataPlaneUrl) {
|
|
844
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
845
|
-
const token = await mintStreamToken(secret, {
|
|
846
|
-
workspaceId,
|
|
847
|
-
sessionId: session.id,
|
|
848
|
-
viewerId,
|
|
849
|
-
leaseEpoch: lease.leaseEpoch,
|
|
850
|
-
port: TERMINAL_STREAM_PORT,
|
|
851
|
-
nowSeconds,
|
|
852
|
-
});
|
|
853
|
-
return {
|
|
854
|
-
url: lease.terminalDataPlaneUrl,
|
|
855
|
-
token,
|
|
856
|
-
expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
|
|
857
|
-
leaseEpoch: lease.leaseEpoch,
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
|
|
861
932
|
// Resume the LIVE box by id (lease.resume_state authoritative), ensure ttyd, and
|
|
862
933
|
// resolve the 7681 tunnel + mint the scoped token, IN-PROCESS.
|
|
863
934
|
const envelope = lease.resumeState;
|
|
@@ -869,12 +940,16 @@ export async function mintTerminalStream(
|
|
|
869
940
|
try {
|
|
870
941
|
established = input.establish
|
|
871
942
|
? await input.establish(envelope)
|
|
872
|
-
: await
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
943
|
+
: await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
|
|
944
|
+
settings,
|
|
945
|
+
envelope,
|
|
946
|
+
{
|
|
947
|
+
sessionId: session.id,
|
|
948
|
+
recovery: "resume-only",
|
|
949
|
+
backendOverride: session.sandboxBackend,
|
|
950
|
+
environment,
|
|
951
|
+
},
|
|
952
|
+
);
|
|
878
953
|
} catch (error) {
|
|
879
954
|
await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
|
|
880
955
|
return null;
|
|
@@ -912,16 +987,19 @@ export async function mintTerminalStream(
|
|
|
912
987
|
throw error;
|
|
913
988
|
}
|
|
914
989
|
|
|
915
|
-
// Record the resolved terminal URL on the lease under the epoch fence. A
|
|
916
|
-
// miss
|
|
917
|
-
// return
|
|
918
|
-
await recordLeaseTerminalDataPlaneUrl(db, {
|
|
990
|
+
// Record the resolved terminal URL on the lease under the epoch fence. A
|
|
991
|
+
// fence miss means this capability belongs to a stale provider epoch; never
|
|
992
|
+
// return it to the caller.
|
|
993
|
+
const recorded = await recordLeaseTerminalDataPlaneUrl(db, {
|
|
919
994
|
accountId,
|
|
920
995
|
workspaceId,
|
|
921
996
|
sandboxGroupId: session.sandboxGroupId,
|
|
922
997
|
expectedEpoch: lease.leaseEpoch,
|
|
923
998
|
terminalDataPlaneUrl: exposed.url,
|
|
924
999
|
});
|
|
1000
|
+
if (!recorded) {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
925
1003
|
|
|
926
1004
|
return {
|
|
927
1005
|
url: exposed.url,
|
|
@@ -1120,7 +1198,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
1120
1198
|
*
|
|
1121
1199
|
* The GET stream-capabilities handshake mints a token scoped to the CALLING
|
|
1122
1200
|
* PRINCIPAL — grant.subjectId — which for an API-key principal is a NON-UUID like
|
|
1123
|
-
* "configured:key". Passing that straight to
|
|
1201
|
+
* "configured:key". Passing that straight to the stream-token payload parser threw a ZodError in
|
|
1124
1202
|
* StreamTokenPayload.parse, which escaped as an uncaught 500 (caps-500 bug). The
|
|
1125
1203
|
* browser's managed-session subject IS a UUID and is returned unchanged, so it is
|
|
1126
1204
|
* unaffected. A non-UUID principal is mapped to a DETERMINISTIC v5-shaped UUID
|