@opengeni/api-router 0.12.7 → 0.14.3

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 (89) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-SD5GJTZ4.js → chunk-36PW33ND.js} +3744 -638
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +75 -5
  65. package/src/github-access.ts +1 -0
  66. package/src/integrations/google-drive.ts +869 -0
  67. package/src/integrations/oauth-client.ts +41 -6
  68. package/src/integrations/slack-bot.ts +756 -26
  69. package/src/mcp/server.ts +226 -42
  70. package/src/mcp/session-view.ts +1 -0
  71. package/src/mcp/toolspace.ts +17 -9
  72. package/src/routes/connections.ts +137 -7
  73. package/src/routes/documents.ts +13 -2
  74. package/src/routes/github.ts +327 -20
  75. package/src/routes/insights.ts +30 -0
  76. package/src/routes/preference-registry.ts +482 -0
  77. package/src/routes/sessions.ts +22 -23
  78. package/src/routes/transcriptions.ts +155 -0
  79. package/src/routes/workspace-capture.ts +108 -8
  80. package/src/routes/workspace-state.ts +61 -0
  81. package/src/sandbox/channel-a.ts +2 -0
  82. package/src/sandbox/rematerialize.ts +111 -4
  83. package/src/sandbox/viewer.ts +29 -12
  84. package/src/transcription/providers/azure-openai.ts +45 -0
  85. package/src/transcription/providers/codex-subscription.ts +100 -0
  86. package/src/transcription/providers/openai.ts +93 -0
  87. package/src/transcription/service.ts +121 -0
  88. package/src/workspace-state-projection.ts +244 -0
  89. package/dist/chunk-SD5GJTZ4.js.map +0 -1
@@ -0,0 +1,155 @@
1
+ import {
2
+ resolveWorkspaceVoiceInputEnabled,
3
+ type TranscribeAudioResponse,
4
+ } from "@opengeni/contracts";
5
+ import { type ApiRouteDeps, requireAccessGrant, TranscriptionServiceError } from "@opengeni/core";
6
+ import { getWorkspace } from "@opengeni/db";
7
+ import type { Hono } from "hono";
8
+
9
+ export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void {
10
+ app.post("/v1/workspaces/:workspaceId/transcriptions", async (c) => {
11
+ const workspaceId = c.req.param("workspaceId");
12
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
13
+ const workspace = await getWorkspace(deps.db, workspaceId);
14
+ if (!workspace) return c.json({ code: "not_found" }, 404);
15
+ if (resolveWorkspaceVoiceInputEnabled(workspace.settings) === false) {
16
+ return c.json({ code: "policy_blocked" }, 403);
17
+ }
18
+ const service = deps.transcription;
19
+ if (!service || !(await service.available())) {
20
+ return c.json({ code: "unavailable" }, 503);
21
+ }
22
+ try {
23
+ const body = await audioRequest(c.req.raw, service.limits().maxSizeBytes);
24
+ const result = await service.transcribe({
25
+ workspaceId,
26
+ accountId: grant.accountId,
27
+ audio: body.audio,
28
+ mimeType: body.mimeType,
29
+ durationSeconds: body.durationSeconds,
30
+ signal: c.req.raw.signal,
31
+ requestId: c.req.header("x-opengeni-correlation-id") ?? crypto.randomUUID(),
32
+ });
33
+ const response: TranscribeAudioResponse = {
34
+ text: result.text,
35
+ languages: result.languages,
36
+ };
37
+ return c.json(response);
38
+ } catch (error) {
39
+ if (error instanceof TranscriptionServiceError) {
40
+ return c.json({ code: error.code }, error.status as never);
41
+ }
42
+ if (isAbort(error)) return c.json({ code: "cancelled" }, 499 as never);
43
+ return c.json({ code: "unknown" }, 500);
44
+ }
45
+ });
46
+ }
47
+
48
+ async function audioRequest(
49
+ request: Request,
50
+ maxSizeBytes: number,
51
+ ): Promise<{ audio: Uint8Array; mimeType: string; durationSeconds?: number }> {
52
+ const contentLength = Number(request.headers.get("content-length"));
53
+ if (Number.isFinite(contentLength) && contentLength > maxSizeBytes + 64 * 1024) {
54
+ throw new TranscriptionServiceError({
55
+ code: "too_large",
56
+ message: "Audio is too large.",
57
+ });
58
+ }
59
+ const contentType = request.headers.get("content-type") ?? "";
60
+ if (contentType.toLowerCase().startsWith("multipart/form-data")) {
61
+ const form = await request.formData();
62
+ const audio = form.get("audio");
63
+ if (!(audio instanceof File)) {
64
+ throw new TranscriptionServiceError({
65
+ code: "invalid_audio",
66
+ message: "Audio file is required.",
67
+ });
68
+ }
69
+ if (audio.size > maxSizeBytes) {
70
+ throw new TranscriptionServiceError({
71
+ code: "too_large",
72
+ message: "Audio is too large.",
73
+ });
74
+ }
75
+ const durationSeconds = durationValue(stringValue(form.get("durationSeconds")));
76
+ return {
77
+ audio: new Uint8Array(await audio.arrayBuffer()),
78
+ mimeType: stringValue(form.get("mimeType")) ?? audio.type,
79
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
80
+ };
81
+ }
82
+ const mimeType = contentType.split(";", 1)[0]?.trim() ?? "";
83
+ if (!mimeType.toLowerCase().startsWith("audio/")) {
84
+ throw new TranscriptionServiceError({
85
+ code: "not_supported",
86
+ message: "Unsupported audio format.",
87
+ });
88
+ }
89
+ const durationSeconds = durationValue(request.headers.get("x-opengeni-audio-duration-seconds"));
90
+ return {
91
+ audio: await readBounded(request.body, maxSizeBytes, request.signal),
92
+ mimeType,
93
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
94
+ };
95
+ }
96
+
97
+ async function readBounded(
98
+ body: ReadableStream<Uint8Array> | null,
99
+ maxSizeBytes: number,
100
+ signal: AbortSignal,
101
+ ): Promise<Uint8Array> {
102
+ if (!body)
103
+ throw new TranscriptionServiceError({
104
+ code: "invalid_audio",
105
+ message: "Audio is required.",
106
+ });
107
+ const reader = body.getReader();
108
+ const chunks: Uint8Array[] = [];
109
+ let size = 0;
110
+ try {
111
+ while (true) {
112
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
113
+ const next = await reader.read();
114
+ if (next.done) break;
115
+ size += next.value.byteLength;
116
+ if (size > maxSizeBytes) {
117
+ await reader.cancel();
118
+ throw new TranscriptionServiceError({
119
+ code: "too_large",
120
+ message: "Audio is too large.",
121
+ });
122
+ }
123
+ chunks.push(next.value);
124
+ }
125
+ } finally {
126
+ reader.releaseLock();
127
+ }
128
+ const audio = new Uint8Array(size);
129
+ let offset = 0;
130
+ for (const chunk of chunks) {
131
+ audio.set(chunk, offset);
132
+ offset += chunk.byteLength;
133
+ }
134
+ return audio;
135
+ }
136
+
137
+ function durationValue(value: string | null | undefined): number | undefined {
138
+ if (value == null || value.trim() === "") return undefined;
139
+ const duration = Number(value);
140
+ if (!Number.isFinite(duration)) {
141
+ throw new TranscriptionServiceError({
142
+ code: "invalid_audio",
143
+ message: "Invalid audio duration.",
144
+ });
145
+ }
146
+ return duration;
147
+ }
148
+
149
+ function stringValue(value: FormDataEntryValue | null): string | undefined {
150
+ return typeof value === "string" ? value : undefined;
151
+ }
152
+
153
+ function isAbort(error: unknown): boolean {
154
+ return error instanceof DOMException && error.name === "AbortError";
155
+ }
@@ -38,6 +38,97 @@ export type CaptureStoragePort = {
38
38
  }) => Promise<{ url: string; expiresAt: Date }>;
39
39
  };
40
40
 
41
+ type LoadedManifest = {
42
+ manifest: WorkspaceCaptureManifest;
43
+ byteLength: number;
44
+ stats: WorkspaceCaptureStats;
45
+ };
46
+
47
+ type ManifestCacheEntry = LoadedManifest & {
48
+ identity: string;
49
+ inlineResponse?: Extract<GetWorkspaceCaptureResponse, { available: true }>;
50
+ };
51
+
52
+ /**
53
+ * Process-local cache for immutable capture manifests. Capture rows point at a
54
+ * revision-specific object key, so a successfully validated blob is safe to
55
+ * reuse as long as the row identity still matches. Both entry count and bytes
56
+ * are bounded; large signed-manifest responses bypass the cache.
57
+ */
58
+ export class WorkspaceCaptureManifestCache {
59
+ readonly #entries = new Map<string, ManifestCacheEntry>();
60
+ #retainedBytes = 0;
61
+
62
+ constructor(
63
+ readonly maxEntries = 64,
64
+ readonly maxBytes = 16 * 1024 * 1024,
65
+ ) {}
66
+
67
+ get(row: WorkspaceCaptureRow): LoadedManifest | null {
68
+ const key = row.manifestKey;
69
+ if (!key) return null;
70
+ const entry = this.#entries.get(key);
71
+ if (!entry || entry.identity !== manifestIdentity(row)) return null;
72
+ this.#entries.delete(key);
73
+ this.#entries.set(key, entry);
74
+ return entry;
75
+ }
76
+
77
+ getInlineResponse(
78
+ row: WorkspaceCaptureRow,
79
+ ): Extract<GetWorkspaceCaptureResponse, { available: true }> | null {
80
+ const key = row.manifestKey;
81
+ if (!key) return null;
82
+ const entry = this.#entries.get(key);
83
+ if (!entry || entry.identity !== manifestIdentity(row) || !entry.inlineResponse) return null;
84
+ this.#entries.delete(key);
85
+ this.#entries.set(key, entry);
86
+ return entry.inlineResponse;
87
+ }
88
+
89
+ setInlineResponse(
90
+ row: WorkspaceCaptureRow,
91
+ response: Extract<GetWorkspaceCaptureResponse, { available: true }>,
92
+ ): void {
93
+ const key = row.manifestKey;
94
+ if (!key) return;
95
+ const entry = this.#entries.get(key);
96
+ if (!entry || entry.identity !== manifestIdentity(row)) return;
97
+ entry.inlineResponse = response;
98
+ }
99
+
100
+ set(row: WorkspaceCaptureRow, loaded: LoadedManifest): void {
101
+ const key = row.manifestKey;
102
+ if (
103
+ !key ||
104
+ this.maxEntries <= 0 ||
105
+ this.maxBytes <= 0 ||
106
+ loaded.byteLength > CAPTURE_INLINE_MANIFEST_MAX_BYTES ||
107
+ loaded.byteLength > this.maxBytes
108
+ ) {
109
+ return;
110
+ }
111
+ const previous = this.#entries.get(key);
112
+ if (previous) {
113
+ this.#retainedBytes -= previous.byteLength;
114
+ this.#entries.delete(key);
115
+ }
116
+ this.#entries.set(key, { ...loaded, identity: manifestIdentity(row) });
117
+ this.#retainedBytes += loaded.byteLength;
118
+ while (this.#entries.size > this.maxEntries || this.#retainedBytes > this.maxBytes) {
119
+ const oldestKey = this.#entries.keys().next().value;
120
+ if (oldestKey === undefined) break;
121
+ const oldest = this.#entries.get(oldestKey);
122
+ this.#entries.delete(oldestKey);
123
+ if (oldest) this.#retainedBytes -= oldest.byteLength;
124
+ }
125
+ }
126
+ }
127
+
128
+ function manifestIdentity(row: WorkspaceCaptureRow): string {
129
+ return `${row.sessionId}\0${row.turnId}\0${row.revision}\0${row.leaseEpoch}\0${row.capturedAt}`;
130
+ }
131
+
41
132
  function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; expiresAt: string } {
42
133
  return { url: signed.url, expiresAt: signed.expiresAt.toISOString() };
43
134
  }
@@ -51,12 +142,11 @@ function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; exp
51
142
  async function loadManifest(
52
143
  row: WorkspaceCaptureRow,
53
144
  storage: CaptureStoragePort,
54
- ): Promise<{
55
- manifest: WorkspaceCaptureManifest;
56
- byteLength: number;
57
- stats: WorkspaceCaptureStats;
58
- } | null> {
145
+ cache?: WorkspaceCaptureManifestCache,
146
+ ): Promise<LoadedManifest | null> {
59
147
  if (!row.manifestKey) return null;
148
+ const cached = cache?.get(row);
149
+ if (cached) return cached;
60
150
  const stats = WorkspaceCaptureStats.safeParse(row.stats);
61
151
  if (!stats.success) {
62
152
  console.warn(
@@ -114,7 +204,9 @@ async function loadManifest(
114
204
  );
115
205
  return null;
116
206
  }
117
- return { manifest, byteLength: blob.bytes.byteLength, stats: servedStats };
207
+ const loaded = { manifest, byteLength: blob.bytes.byteLength, stats: servedStats };
208
+ cache?.set(row, loaded);
209
+ return loaded;
118
210
  }
119
211
 
120
212
  /**
@@ -126,6 +218,7 @@ async function loadManifest(
126
218
  export async function serveWorkspaceCapture(
127
219
  row: WorkspaceCaptureRow | null,
128
220
  storage: CaptureStoragePort,
221
+ cache?: WorkspaceCaptureManifestCache,
129
222
  ): Promise<GetWorkspaceCaptureResponse> {
130
223
  if (!row) {
131
224
  return { available: false };
@@ -148,11 +241,13 @@ export async function serveWorkspaceCapture(
148
241
  });
149
242
  }
150
243
  if (row.state !== "available" || !row.manifestKey) return { available: false };
244
+ const cachedResponse = cache?.getInlineResponse(row);
245
+ if (cachedResponse) return cachedResponse;
151
246
  // Validate every manifest before serving it, including the rare >2MB signed
152
247
  // path. Previously that branch signed arbitrary bytes merely because they
153
248
  // exceeded the inline cap, allowing a poison/mis-keyed blob to bypass both the
154
249
  // schema and row-identity checks.
155
- const loaded = await loadManifest(row, storage);
250
+ const loaded = await loadManifest(row, storage, cache);
156
251
  if (!loaded) return { available: false };
157
252
  const meta = {
158
253
  available: true as const,
@@ -164,11 +259,16 @@ export async function serveWorkspaceCapture(
164
259
  stats: loaded.stats,
165
260
  };
166
261
  if (loaded.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
167
- return GetWorkspaceCaptureResponse.parse({
262
+ const response = GetWorkspaceCaptureResponse.parse({
168
263
  ...meta,
169
264
  manifest: loaded.manifest,
170
265
  manifestUrl: null,
171
266
  });
267
+ if (!response.available) {
268
+ throw new Error("validated inline capture response lost its available discriminator");
269
+ }
270
+ cache?.setInlineResponse(row, response);
271
+ return response;
172
272
  }
173
273
  const signed = await storage.createGetUrl({
174
274
  key: row.manifestKey,
@@ -0,0 +1,61 @@
1
+ import {
2
+ WORKSPACE_STATE_MAX_BASES,
3
+ WORKSPACE_STATE_MAX_TOPICS,
4
+ WORKSPACE_STATE_TOPIC_MAX_CHARS,
5
+ WorkspaceStateResponse,
6
+ } from "@opengeni/contracts";
7
+ import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
8
+ import {
9
+ getWorkspace,
10
+ listWorkspaceStateMemoryRecords,
11
+ listWorkspaceInstructionPolicyRevisions,
12
+ } from "@opengeni/db";
13
+ import { getDocumentInventory } from "@opengeni/documents";
14
+ import type { Hono } from "hono";
15
+ import { HTTPException } from "hono/http-exception";
16
+
17
+ import { projectWorkspaceState } from "../workspace-state-projection";
18
+
19
+ export function registerWorkspaceStateRoutes(app: Hono, deps: ApiRouteDeps): void {
20
+ app.get("/v1/workspaces/:workspaceId/workspace-state", async (context) => {
21
+ const workspaceId = context.req.param("workspaceId");
22
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
23
+ const generatedAt = new Date().toISOString();
24
+ const canInspectKnowledge = hasPermission(grant.permissions, "documents:search");
25
+
26
+ const [workspace, policies, knowledge] = await Promise.all([
27
+ getWorkspace(deps.db, workspaceId),
28
+ listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, { limit: 1 }),
29
+ canInspectKnowledge
30
+ ? (async () => {
31
+ const [documents, memories] = await Promise.all([
32
+ getDocumentInventory(deps.db, workspaceId, {
33
+ baseLimit: WORKSPACE_STATE_MAX_BASES,
34
+ topicLimit: WORKSPACE_STATE_MAX_TOPICS,
35
+ topicMaxChars: WORKSPACE_STATE_TOPIC_MAX_CHARS,
36
+ access: { viewerSubjectId: grant.subjectId },
37
+ }),
38
+ listWorkspaceStateMemoryRecords(deps.db, workspaceId),
39
+ ]);
40
+ return { documents, memories };
41
+ })()
42
+ : Promise.resolve(null),
43
+ ]);
44
+
45
+ if (!workspace) {
46
+ throw new HTTPException(404, { message: "workspace not found" });
47
+ }
48
+ context.header("cache-control", "private, no-store");
49
+ return context.json(
50
+ WorkspaceStateResponse.parse(
51
+ projectWorkspaceState({
52
+ workspaceId,
53
+ generatedAt,
54
+ workspaceAgentInstructions: workspace.agentInstructions,
55
+ policies,
56
+ knowledge,
57
+ }),
58
+ ),
59
+ );
60
+ });
61
+ }
@@ -21,6 +21,7 @@ import {
21
21
  applyGitAuthPointerEnvironment,
22
22
  hasGitCredentialRepositorySelection,
23
23
  hasGitHubRepositorySelection,
24
+ sandboxArchiveCaptureTimeoutMs,
24
25
  stableSandboxEnvironmentForRun,
25
26
  type Settings,
26
27
  } from "@opengeni/config";
@@ -247,6 +248,7 @@ export async function withChannelA<T>(
247
248
  os: session.sandboxOs,
248
249
  leaseTtlMs,
249
250
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
251
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
250
252
  });
251
253
 
252
254
  if (acquired.role === "blocked") {
@@ -1,5 +1,6 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import {
3
+ adoptLegacyModalCheckpointArtifact,
3
4
  beginSandboxRematerialization,
4
5
  commitWarmingToWarm,
5
6
  failSandboxRematerialization,
@@ -12,9 +13,13 @@ import {
12
13
  type LeaseSnapshot,
13
14
  } from "@opengeni/db";
14
15
  import {
16
+ describeLegacyNativeSnapshotArchive,
15
17
  establishSandboxSessionFromEnvelope,
16
18
  isProviderSandboxNotFoundError,
19
+ modalSessionMatchesCheckpointProviderBinding,
20
+ parseWorkspaceArchiveDescriptor,
17
21
  requirePersistableReplacementSandboxEnvelope,
22
+ resolveModalCheckpointProviderBindingForSession,
18
23
  serializeReplacementSandboxEnvelope,
19
24
  tagModalSandbox,
20
25
  verifySandboxExecReadiness,
@@ -33,6 +38,20 @@ function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean
33
38
  );
34
39
  }
35
40
 
41
+ function legacyNativeArchiveFromEnvelope(envelope: Record<string, unknown> | null) {
42
+ const sessionState =
43
+ envelope?.sessionState && typeof envelope.sessionState === "object"
44
+ ? (envelope.sessionState as Record<string, unknown>)
45
+ : null;
46
+ if (!sessionState) return null;
47
+ const existing = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
48
+ if (existing?.version === 2) return null;
49
+ return describeLegacyNativeSnapshotArchive(
50
+ sessionState.workspaceArchive,
51
+ existing?.version === 1 ? Date.parse(existing.capturedAt) : Date.now(),
52
+ );
53
+ }
54
+
36
55
  function withoutProviderIdentity(
37
56
  envelope: Record<string, unknown> | null,
38
57
  ): Record<string, unknown> | null {
@@ -48,7 +67,9 @@ function withoutProviderIdentity(
48
67
 
49
68
  async function terminateCreated(established: EstablishedSandboxSession | null): Promise<boolean> {
50
69
  if (!established) return true;
51
- const client = established.client as { delete?: (state: unknown) => Promise<unknown> };
70
+ const client = established.client as {
71
+ delete?: (state: unknown) => Promise<unknown>;
72
+ };
52
73
  try {
53
74
  if (typeof client.delete === "function" && established.sessionState !== undefined) {
54
75
  await client.delete(established.sessionState);
@@ -93,20 +114,30 @@ export async function establishApiSandboxSpawner(input: {
93
114
  hasWorkspaceArchive(input.fallbackEnvelope)
94
115
  ? withoutProviderIdentity(input.fallbackEnvelope)
95
116
  : null;
96
- const spawnEnvelope =
117
+ let spawnEnvelope =
97
118
  fallbackArchiveEnvelope ?? input.acquiredLease.resumeState ?? input.fallbackEnvelope;
98
119
  const archiveSource =
99
120
  input.acquiredLease.recovery.archive.status === "none"
100
121
  ? fallbackArchiveEnvelope
101
122
  : input.acquiredLease.resumeState;
102
123
  let established: EstablishedSandboxSession | null = null;
103
- let rematerialization: { id: string; selectedRevision: string } | null = null;
124
+ let rematerialization: {
125
+ id: string;
126
+ selectedRevision: string;
127
+ workspaceGeneration: number;
128
+ providerBindingKey: string | null;
129
+ legacyCheckpoint: ReturnType<typeof legacyNativeArchiveFromEnvelope>;
130
+ legacyProviderBinding: Awaited<
131
+ ReturnType<typeof resolveModalCheckpointProviderBindingForSession>
132
+ > | null;
133
+ } | null = null;
104
134
  try {
105
135
  if (
106
136
  input.acquiredLease.recovery.archive.status === "available" ||
107
137
  hasWorkspaceArchive(archiveSource)
108
138
  ) {
109
139
  const id = crypto.randomUUID();
140
+ const legacyNativeArchive = legacyNativeArchiveFromEnvelope(archiveSource);
110
141
  const begun = await beginSandboxRematerialization(input.db, {
111
142
  accountId: input.accountId,
112
143
  workspaceId: input.workspaceId,
@@ -114,6 +145,7 @@ export async function establishApiSandboxSpawner(input: {
114
145
  expectedEpoch: input.expectedEpoch,
115
146
  rematerializationId: id,
116
147
  archiveSource,
148
+ legacyNativeArchive,
117
149
  });
118
150
  if (begun.status !== "started") {
119
151
  if (begun.code === "stale_epoch" || begun.code === "attempt_conflict") {
@@ -129,6 +161,7 @@ export async function establishApiSandboxSpawner(input: {
129
161
  begun.lease?.recovery ?? input.acquiredLease.recovery,
130
162
  );
131
163
  }
164
+ spawnEnvelope = begun.lease.resumeState ?? spawnEnvelope;
132
165
  const selectedRevision = begun.lease.recovery.restore.selectedRevision;
133
166
  if (!selectedRevision) {
134
167
  throw new WorkspaceArchiveIntegrityError(
@@ -136,7 +169,14 @@ export async function establishApiSandboxSpawner(input: {
136
169
  "sandbox rematerialization selected no durable archive revision",
137
170
  );
138
171
  }
139
- rematerialization = { id, selectedRevision };
172
+ rematerialization = {
173
+ id,
174
+ selectedRevision,
175
+ workspaceGeneration: begun.lease.workspaceGeneration,
176
+ providerBindingKey: begun.checkpointArtifact?.providerBindingKey ?? null,
177
+ legacyCheckpoint: begun.checkpointArtifact === null ? legacyNativeArchive : null,
178
+ legacyProviderBinding: null,
179
+ };
140
180
  } else if (input.acquiredLease.recovery.archive.status !== "none") {
141
181
  throw new SandboxLeaseRecoveryBlockedError(
142
182
  input.sandboxGroupId,
@@ -146,6 +186,7 @@ export async function establishApiSandboxSpawner(input: {
146
186
  );
147
187
  }
148
188
 
189
+ const providerCreateStartedAt = new Date();
149
190
  established = await establishSandboxSessionFromEnvelope(input.settings, spawnEnvelope, {
150
191
  sessionId: input.sessionId,
151
192
  recovery: "create-or-restore",
@@ -153,6 +194,37 @@ export async function establishApiSandboxSpawner(input: {
153
194
  environment: input.environment,
154
195
  onSandboxCreated: async (created) => {
155
196
  established = created;
197
+ if (
198
+ rematerialization &&
199
+ (rematerialization.providerBindingKey || rematerialization.legacyCheckpoint)
200
+ ) {
201
+ if (created.backendId !== "modal") {
202
+ throw new WorkspaceArchiveIntegrityError(
203
+ "native_snapshot_reference_invalid",
204
+ "Modal checkpoint restore resolved a non-Modal sandbox backend",
205
+ );
206
+ }
207
+ if (rematerialization.providerBindingKey) {
208
+ if (
209
+ !(await modalSessionMatchesCheckpointProviderBinding(
210
+ input.settings,
211
+ created.session,
212
+ rematerialization.providerBindingKey,
213
+ ))
214
+ ) {
215
+ throw new WorkspaceArchiveIntegrityError(
216
+ "native_snapshot_reference_invalid",
217
+ "Modal checkpoint restore refused because the authenticated provider workspace changed",
218
+ );
219
+ }
220
+ } else {
221
+ rematerialization.legacyProviderBinding =
222
+ await resolveModalCheckpointProviderBindingForSession(
223
+ input.settings,
224
+ created.session,
225
+ );
226
+ }
227
+ }
156
228
  const resumeState = requirePersistableReplacementSandboxEnvelope(
157
229
  await serializeReplacementSandboxEnvelope(created, spawnEnvelope),
158
230
  created.backendId,
@@ -166,6 +238,14 @@ export async function establishApiSandboxSpawner(input: {
166
238
  instanceId: created.instanceId,
167
239
  resumeBackendId: created.backendId,
168
240
  resumeState,
241
+ ...(created.backendId === "modal"
242
+ ? {
243
+ providerCreatedAt: providerCreateStartedAt,
244
+ providerDeadlineAt: new Date(
245
+ providerCreateStartedAt.getTime() + input.settings.modalTimeoutSeconds * 1000,
246
+ ),
247
+ }
248
+ : {}),
169
249
  leaseTtlMs: input.settings.sandboxLeaseTtlMs,
170
250
  warmingLeaseTtlMs: input.settings.sandboxWarmingTimeoutMs,
171
251
  });
@@ -187,6 +267,33 @@ export async function establishApiSandboxSpawner(input: {
187
267
  `hydrated archive revision ${descriptor.revision} does not match the selected rematerialization revision`,
188
268
  );
189
269
  }
270
+ if (rematerialization.legacyCheckpoint) {
271
+ const binding = rematerialization.legacyProviderBinding;
272
+ if (!binding) {
273
+ throw new WorkspaceArchiveIntegrityError(
274
+ "native_snapshot_reference_invalid",
275
+ "Legacy Modal checkpoint restore produced no authenticated provider identity",
276
+ );
277
+ }
278
+ const adopted = await adoptLegacyModalCheckpointArtifact(input.db, {
279
+ accountId: input.accountId,
280
+ workspaceId: input.workspaceId,
281
+ sandboxGroupId: input.sandboxGroupId,
282
+ leaseId: input.acquiredLease.id,
283
+ leaseEpoch: input.expectedEpoch,
284
+ workspaceGeneration: rematerialization.workspaceGeneration,
285
+ slot: "current",
286
+ archiveBase64: rematerialization.legacyCheckpoint.archiveBase64,
287
+ descriptor: rematerialization.legacyCheckpoint.descriptor,
288
+ providerBindingKey: binding.key,
289
+ providerBinding: binding.binding,
290
+ rematerializationId: rematerialization.id,
291
+ });
292
+ if (!adopted) {
293
+ throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
294
+ }
295
+ rematerialization.providerBindingKey = binding.key;
296
+ }
190
297
  const verifying = await markSandboxRestoreVerifying(input.db, {
191
298
  accountId: input.accountId,
192
299
  workspaceId: input.workspaceId,
@@ -24,6 +24,7 @@ import {
24
24
  hasGitCredentialRepositorySelection,
25
25
  hasGitHubRepositorySelection,
26
26
  resolveStreamTokenSecret,
27
+ sandboxArchiveCaptureTimeoutMs,
27
28
  stableSandboxEnvironmentForRun,
28
29
  } from "@opengeni/config";
29
30
  import type { Settings } from "@opengeni/config";
@@ -42,6 +43,7 @@ import {
42
43
  recordLeaseTerminalDataPlaneUrl,
43
44
  releaseLeaseHolder,
44
45
  SandboxLeaseSupersededError,
46
+ SandboxViewerAdmissionBlockedError,
45
47
  type Database,
46
48
  type LeaseSnapshot,
47
49
  type SandboxRecord,
@@ -195,18 +197,33 @@ export async function attachViewer(
195
197
  });
196
198
  };
197
199
 
198
- const acquired = await acquireLease(db, {
199
- accountId,
200
- workspaceId,
201
- sandboxGroupId,
202
- kind: "viewer",
203
- holderId: viewerId,
204
- subjectId: session.id,
205
- backend: session.sandboxBackend,
206
- os: session.sandboxOs,
207
- leaseTtlMs,
208
- warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
209
- });
200
+ let acquired: Awaited<ReturnType<typeof acquireLease>>;
201
+ try {
202
+ acquired = await acquireLease(db, {
203
+ accountId,
204
+ workspaceId,
205
+ sandboxGroupId,
206
+ kind: "viewer",
207
+ holderId: viewerId,
208
+ subjectId: session.id,
209
+ backend: session.sandboxBackend,
210
+ os: session.sandboxOs,
211
+ leaseTtlMs,
212
+ warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
213
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
214
+ });
215
+ } catch (error) {
216
+ if (error instanceof SandboxViewerAdmissionBlockedError) {
217
+ throw new HTTPException(error.reason === "balance" ? 402 : 429, {
218
+ message:
219
+ error.reason === "balance"
220
+ ? "insufficient OpenGeni credits for an idle sandbox viewer"
221
+ : "workspace sandbox warm allowance exhausted",
222
+ cause: error,
223
+ });
224
+ }
225
+ throw error;
226
+ }
210
227
 
211
228
  // FENCED: a newer epoch re-established the box. Release our just-registered
212
229
  // holder and surface a 409 — the client re-reads capabilities and re-attaches.