@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,265 @@
1
+ // apps/api/src/sandbox/channel-a.ts — the API-DIRECT Channel-A seam (P4.4).
2
+ //
3
+ // The structured services (FileSystem / Git / Terminal) are SYNCHRONOUS point
4
+ // queries served client -> API -> box IN-PROCESS. Each call:
5
+ //
6
+ // 1. acquires a viewer-kind lease holder (warming the box when cold — the
7
+ // same cold->warming CAS attachViewer runs; a Postgres txn the API OWNS),
8
+ // 2. resumes the box BY ID from the group lease's resume_state envelope,
9
+ // 3. builds ONE SandboxChannelAService around the live `session` handle,
10
+ // 4. runs the op (fsList/gitDiff/ptyWrite/...), returns inline JSON,
11
+ // 5. releases the viewer holder + drops the live handle.
12
+ //
13
+ // NO Temporal, NO worker RPC, NO NATS round-trip in this path — reads never ride
14
+ // the bus (which would corrupt SSE gap-fill). Only the side-effect NOTIFICATIONS
15
+ // (fs.changed/git.changed/terminal.pty.*) ride A1 via appendAndPublishEvents.
16
+ //
17
+ // IMPORT DISCIPLINE: sandbox symbols come ONLY from @opengeni/runtime/sandbox
18
+ // (the agent-loop-free leaf) — enforced by sandbox-access-import-guard.test.ts.
19
+
20
+ import { applyGitAuthPointerEnvironment, hasGitHubRepositorySelection, stableSandboxEnvironmentForRun, type Settings } from "@opengeni/config";
21
+ import { githubAppBotIdentity } from "@opengeni/github";
22
+ import type { Session } from "@opengeni/contracts";
23
+ import {
24
+ acquireLease,
25
+ commitWarmingToWarm,
26
+ failWarmingToCold,
27
+ getSandboxSessionEnvelope,
28
+ loadWorkspaceEnvironmentForRun,
29
+ readLease,
30
+ releaseLeaseHolder,
31
+ SandboxLeaseSupersededError,
32
+ type Database,
33
+ type LeaseSnapshot,
34
+ } from "@opengeni/db";
35
+ import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
36
+ import { HTTPException } from "hono/http-exception";
37
+
38
+ import {
39
+ establishSandboxSessionFromEnvelope,
40
+ serializeEstablishedSandboxEnvelope,
41
+ SandboxChannelAService,
42
+ ChannelAConflictError,
43
+ ChannelANotFoundError,
44
+ ChannelAUnsupportedError,
45
+ ChannelAValidationError,
46
+ type ChannelASession,
47
+ type EstablishedSandboxSession,
48
+ } from "@opengeni/runtime/sandbox";
49
+ import { routingEnabled, wrapChannelABoxWithRouting } from "@opengeni/core";
50
+
51
+ export type ChannelAServices = {
52
+ db: Database;
53
+ settings: Settings;
54
+ bus: EventBus;
55
+ };
56
+
57
+ export type ChannelAContext = {
58
+ accountId: string;
59
+ workspaceId: string;
60
+ session: Session;
61
+ // The principal that drives the op (for emit attribution + pty opened_by).
62
+ subjectId: string;
63
+ };
64
+
65
+ // The live op surface handed to a route's callback: the service + the live lease
66
+ // (for the pty exec-session epoch fence + revision seeding).
67
+ export type ChannelAHandle = {
68
+ service: SandboxChannelAService;
69
+ lease: LeaseSnapshot;
70
+ };
71
+
72
+ /**
73
+ * Run a Channel-A op against a live box, API-direct. Acquires a viewer holder
74
+ * (warming the box when cold), resumes by id, builds the service, runs `fn`, and
75
+ * ALWAYS releases the holder + drops the handle in `finally`. Maps the service's
76
+ * typed errors to HTTP status (the route never sees a raw ChannelA*Error).
77
+ *
78
+ * Gated behind sandboxOwnershipEnabled at the route (the lease is dormant
79
+ * otherwise). A `backend:none` session has no box -> 409 before touching it.
80
+ */
81
+ export async function withChannelA<T>(
82
+ services: ChannelAServices,
83
+ ctx: ChannelAContext,
84
+ fn: (handle: ChannelAHandle) => Promise<T>,
85
+ ): Promise<T> {
86
+ const { db, settings, bus } = services;
87
+ const { accountId, workspaceId, session, subjectId } = ctx;
88
+
89
+ if (session.sandboxBackend === "none") {
90
+ throw new HTTPException(409, { message: "sandbox not available" });
91
+ }
92
+
93
+ const sandboxGroupId = session.sandboxGroupId;
94
+ const viewerId = crypto.randomUUID();
95
+ const leaseTtlMs = settings.sandboxLeaseTtlMs;
96
+
97
+ const release = async (): Promise<void> => {
98
+ await releaseLeaseHolder(db, {
99
+ accountId,
100
+ workspaceId,
101
+ sandboxGroupId,
102
+ kind: "viewer",
103
+ holderId: viewerId,
104
+ idleGraceMs: settings.sandboxIdleGraceMs,
105
+ });
106
+ };
107
+
108
+ // Acquire a viewer holder; the cold->warming CAS spawns the box when cold.
109
+ const acquired = await acquireLease(db, {
110
+ accountId,
111
+ workspaceId,
112
+ sandboxGroupId,
113
+ kind: "viewer",
114
+ holderId: viewerId,
115
+ subjectId: session.id,
116
+ backend: session.sandboxBackend,
117
+ os: session.sandboxOs,
118
+ leaseTtlMs,
119
+ });
120
+
121
+ if (acquired.role === "fenced") {
122
+ await release();
123
+ throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry` });
124
+ }
125
+
126
+ let established: EstablishedSandboxSession | undefined;
127
+ let leaseSnapshot: LeaseSnapshot = acquired.lease;
128
+
129
+ try {
130
+ const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
131
+ // The STABLE run-environment a COLD box must be created with so a later worker
132
+ // turn's agent-manifest apply finds an EMPTY env delta (config base + git
133
+ // identity + decrypted workspace env + HOME + — for a repo-attached session —
134
+ // the stable git-auth pointers the turn declares). Only the rotating token
135
+ // VALUE stays off (it lives in the box file the clone hook seeds). Keyed off
136
+ // the SESSION's backend (the establish below passes backendOverride:
137
+ // session.sandboxBackend, and HOME/token-file/askpass are backend-derived),
138
+ // NOT the deployment default — mirrors sessionAttachEnvironment.
139
+ const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(db, settings, workspaceId, session.environmentId);
140
+ const settingsForSession = session.sandboxBackend !== settings.sandboxBackend
141
+ ? { ...settings, sandboxBackend: session.sandboxBackend }
142
+ : settings;
143
+ const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
144
+ if (hasGitHubRepositorySelection(session.resources)) {
145
+ applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(settings));
146
+ }
147
+
148
+ if (acquired.role === "spawner") {
149
+ // We won the cold->warming CAS: establish the box from the envelope, then
150
+ // commit warm. The established handle IS our live handle for the op.
151
+ const expectedEpoch = acquired.lease.leaseEpoch;
152
+ // Prefer the COLD lease's preserved resume_state when it carries a persisted
153
+ // /workspace snapshot (confirmDrainCold keeps a minimal archive-only envelope
154
+ // across draining->cold for exactly this re-warm). establishSandboxSessionFromEnvelope
155
+ // cold-creates a fresh box and replays the archive via hydrateWorkspace, so
156
+ // /workspace survives the box churn (sandbox-file-persistence). No archive ->
157
+ // the bare session envelope (a never-warmed cold start). The order matters:
158
+ // resume_state is the lease's authoritative box descriptor; the session
159
+ // `_sandbox` envelope is only the per-session fallback.
160
+ const spawnEnvelope = acquired.lease.resumeState ?? envelope;
161
+ try {
162
+ established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
163
+ sessionId: session.id,
164
+ backendOverride: session.sandboxBackend,
165
+ environment,
166
+ });
167
+ } catch (error) {
168
+ await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
169
+ throw new HTTPException(409, { message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})` });
170
+ }
171
+ // Persist the LIVE box as the lease's resume_state so the NEXT op resumes
172
+ // this box by id rather than cold-creating a rival (the box-churn the
173
+ // prove-it surfaced). Fall back to the session envelope when serialize is
174
+ // unavailable.
175
+ const resumeEnvelope = (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
176
+ const committed = await commitWarmingToWarm(db, {
177
+ accountId,
178
+ workspaceId,
179
+ sandboxGroupId,
180
+ expectedEpoch,
181
+ instanceId: established.instanceId,
182
+ dataPlaneUrl: acquired.lease.dataPlaneUrl,
183
+ resumeBackendId: established.backendId,
184
+ resumeState: resumeEnvelope,
185
+ leaseTtlMs,
186
+ });
187
+ if (!committed.committed || !committed.lease) {
188
+ throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${expectedEpoch}); retry` });
189
+ }
190
+ leaseSnapshot = committed.lease;
191
+ } else {
192
+ // ATTACHED / REARMED: the box is live. Read the lease to get the
193
+ // authoritative resume_state, then resume by id for this op.
194
+ const live = await readLease(db, workspaceId, sandboxGroupId);
195
+ if (live) leaseSnapshot = live;
196
+ const resumeEnvelope = leaseSnapshot.resumeState ?? envelope;
197
+ established = await establishSandboxSessionFromEnvelope(settings, resumeEnvelope, {
198
+ sessionId: session.id,
199
+ backendOverride: session.sandboxBackend,
200
+ environment,
201
+ });
202
+ }
203
+
204
+ const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
205
+ await appendAndPublishEvents(
206
+ db,
207
+ bus,
208
+ workspaceId,
209
+ session.id,
210
+ // SessionEventType is a string enum at the contract; the producer parses
211
+ // the payload, so this cast is the same shape the worker emits.
212
+ events.map((e) => ({ type: e.type as never, payload: e.payload })),
213
+ );
214
+ };
215
+
216
+ // M7 hot-swap: when the selfhosted feature is on, route the Channel-A op to
217
+ // the session's currently-active sandbox (not always the group box). The
218
+ // proxy re-reads (active_sandbox_id, active_epoch) on each session method the
219
+ // service calls and dispatches to the active backend (the group box by
220
+ // default, or a swapped-to selfhosted machine). With the flag off the
221
+ // established group session is used unchanged.
222
+ const routedSession = routingEnabled(settings)
223
+ ? wrapChannelABoxWithRouting({ db, settings, bus }, { workspaceId, sessionId: session.id }, established).session
224
+ : established.session;
225
+
226
+ const service = new SandboxChannelAService({
227
+ session: routedSession as ChannelASession,
228
+ leaseEpoch: leaseSnapshot.leaseEpoch,
229
+ emit,
230
+ });
231
+
232
+ return await fn({ service, lease: leaseSnapshot });
233
+ } catch (error) {
234
+ throw mapChannelAError(error);
235
+ } finally {
236
+ await release();
237
+ await dropEstablishedHandle(established);
238
+ }
239
+ }
240
+
241
+ /** Map the service's typed errors to HTTP status (the §5.3 matrix). Re-throws an
242
+ * already-HTTPException unchanged. */
243
+ export function mapChannelAError(error: unknown): unknown {
244
+ if (error instanceof HTTPException) return error;
245
+ if (error instanceof ChannelAValidationError) return new HTTPException(400, { message: error.message });
246
+ if (error instanceof ChannelANotFoundError) return new HTTPException(404, { message: error.message });
247
+ if (error instanceof ChannelAConflictError) return new HTTPException(409, { message: error.message });
248
+ if (error instanceof ChannelAUnsupportedError) return new HTTPException(409, { message: error.message });
249
+ return error;
250
+ }
251
+
252
+ // Drop a transiently-established, NON-OWNED handle WITHOUT terminating the box.
253
+ // The box is owned by the LEASE (resumed by id); this handle is incidental.
254
+ //
255
+ // CRITICAL (deployed-integration bug, prove-it D2): a provider session's
256
+ // `close()` is NOT a neutral local-resource free — Modal's session.close() calls
257
+ // sandbox.terminate(), KILLING THE BOX. Calling it after each Channel-A op
258
+ // destroyed the box mid-flight, so a subsequent fs.read/git/exec hit a different
259
+ // (cold-restored) box and 404'd. We DO NOT close the session; only the reaper
260
+ // (provider stop at refcount 0) terminates a box.
261
+ async function dropEstablishedHandle(established: EstablishedSandboxSession | undefined): Promise<void> {
262
+ // No-op beyond dropping the reference: the lease owns lifecycle, the reaper
263
+ // owns teardown. Never session.close()/terminate() a non-owned handle here.
264
+ void established;
265
+ }