@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.
- package/dist/app.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- 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
|
+
}
|