@opengeni/api-router 0.9.0 → 0.11.2
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-QOYQBYHM.js → chunk-BFWSDESE.js} +9875 -9276
- package/dist/chunk-BFWSDESE.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/mcp/server.ts +91 -77
- package/src/routes/install.ts +8 -9
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +482 -142
- 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 +200 -128
- package/dist/chunk-QOYQBYHM.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;
|
|
@@ -682,15 +767,18 @@ export async function mintDesktopStream(
|
|
|
682
767
|
}
|
|
683
768
|
|
|
684
769
|
// Record the resolved URL on the lease under the epoch fence (rotation +
|
|
685
|
-
// disclosure). A fence miss
|
|
686
|
-
//
|
|
687
|
-
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, {
|
|
688
773
|
accountId,
|
|
689
774
|
workspaceId,
|
|
690
775
|
sandboxGroupId: session.sandboxGroupId,
|
|
691
776
|
expectedEpoch: lease.leaseEpoch,
|
|
692
777
|
dataPlaneUrl: exposed.url,
|
|
693
778
|
});
|
|
779
|
+
if (!recorded) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
694
782
|
|
|
695
783
|
const mint: DesktopStreamMint = {
|
|
696
784
|
url: exposed.url,
|
|
@@ -744,8 +832,8 @@ export async function mintDesktopStream(
|
|
|
744
832
|
// server (ensureTerminalServer), resolves the provider's scoped tunnel for port
|
|
745
833
|
// 7681 (a SEPARATE tunnel from the 6080 desktop noVNC → a different URL), mints
|
|
746
834
|
// the scoped per-viewer stream token, and records the resolved URL on the lease's
|
|
747
|
-
// terminal_data_plane_url column under the epoch fence.
|
|
748
|
-
//
|
|
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.
|
|
749
837
|
//
|
|
750
838
|
// It does NOT require the desktop to be on — it gates on the separate
|
|
751
839
|
// sandboxTerminalEnabled toggle. Degradation (no secret, headless backend, ttyd
|
|
@@ -841,29 +929,6 @@ export async function mintTerminalStream(
|
|
|
841
929
|
return null;
|
|
842
930
|
}
|
|
843
931
|
|
|
844
|
-
// FAST PATH: the terminal tunnel URL is stable for the life of the (epoch-fenced)
|
|
845
|
-
// box, so when the lease already caches it, mint ONLY a fresh scoped token (HMAC,
|
|
846
|
-
// sub-millisecond) against the cached URL — no box resume/exec at all. A rollover
|
|
847
|
-
// advances the epoch and clears terminalDataPlaneUrl (commitWarmingToWarm), so a
|
|
848
|
-
// cached URL here is always the current epoch's live ttyd tunnel.
|
|
849
|
-
if (lease.terminalDataPlaneUrl) {
|
|
850
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
851
|
-
const token = await mintStreamToken(secret, {
|
|
852
|
-
workspaceId,
|
|
853
|
-
sessionId: session.id,
|
|
854
|
-
viewerId,
|
|
855
|
-
leaseEpoch: lease.leaseEpoch,
|
|
856
|
-
port: TERMINAL_STREAM_PORT,
|
|
857
|
-
nowSeconds,
|
|
858
|
-
});
|
|
859
|
-
return {
|
|
860
|
-
url: lease.terminalDataPlaneUrl,
|
|
861
|
-
token,
|
|
862
|
-
expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
|
|
863
|
-
leaseEpoch: lease.leaseEpoch,
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
|
|
867
932
|
// Resume the LIVE box by id (lease.resume_state authoritative), ensure ttyd, and
|
|
868
933
|
// resolve the 7681 tunnel + mint the scoped token, IN-PROCESS.
|
|
869
934
|
const envelope = lease.resumeState;
|
|
@@ -875,12 +940,16 @@ export async function mintTerminalStream(
|
|
|
875
940
|
try {
|
|
876
941
|
established = input.establish
|
|
877
942
|
? await input.establish(envelope)
|
|
878
|
-
: await
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
+
);
|
|
884
953
|
} catch (error) {
|
|
885
954
|
await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
|
|
886
955
|
return null;
|
|
@@ -918,16 +987,19 @@ export async function mintTerminalStream(
|
|
|
918
987
|
throw error;
|
|
919
988
|
}
|
|
920
989
|
|
|
921
|
-
// Record the resolved terminal URL on the lease under the epoch fence. A
|
|
922
|
-
// miss
|
|
923
|
-
// return
|
|
924
|
-
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, {
|
|
925
994
|
accountId,
|
|
926
995
|
workspaceId,
|
|
927
996
|
sandboxGroupId: session.sandboxGroupId,
|
|
928
997
|
expectedEpoch: lease.leaseEpoch,
|
|
929
998
|
terminalDataPlaneUrl: exposed.url,
|
|
930
999
|
});
|
|
1000
|
+
if (!recorded) {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
931
1003
|
|
|
932
1004
|
return {
|
|
933
1005
|
url: exposed.url,
|
|
@@ -1126,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}$/
|
|
|
1126
1198
|
*
|
|
1127
1199
|
* The GET stream-capabilities handshake mints a token scoped to the CALLING
|
|
1128
1200
|
* PRINCIPAL — grant.subjectId — which for an API-key principal is a NON-UUID like
|
|
1129
|
-
* "configured:key". Passing that straight to
|
|
1201
|
+
* "configured:key". Passing that straight to the stream-token payload parser threw a ZodError in
|
|
1130
1202
|
* StreamTokenPayload.parse, which escaped as an uncaught 500 (caps-500 bug). The
|
|
1131
1203
|
* browser's managed-session subject IS a UUID and is returned unchanged, so it is
|
|
1132
1204
|
* unaffected. A non-UUID principal is mapped to a DETERMINISTIC v5-shaped UUID
|