@opengeni/api-router 0.22.2 → 0.23.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/auth/managed-auth.d.ts +0 -30
- package/dist/{chunk-HWXJW5C7.js → chunk-T4T2PGU4.js} +3036 -1362
- package/dist/chunk-T4T2PGU4.js.map +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.js +29 -9
- package/dist/index.js.map +1 -1
- package/dist/integrations/oauth-client.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +7 -1
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +350 -0
- package/dist/mcp/toolspace.d.ts +9 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +5 -1
- package/package.json +12 -12
- package/src/app.ts +3 -4
- package/src/auth/managed-auth.ts +0 -16
- package/src/http/sse.ts +101 -6
- package/src/index.ts +28 -3
- package/src/integrations/oauth-client.ts +36 -56
- package/src/integrations/slack-interactions.ts +123 -15
- package/src/mcp/documents.ts +42 -25
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +608 -0
- package/src/mcp/server.ts +812 -182
- package/src/mcp/toolspace.ts +75 -71
- package/src/observability.ts +3 -3
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/codex.ts +7 -4
- package/src/routes/connections.ts +74 -3
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +175 -65
- package/src/routes/install.ts +31 -1
- package/src/routes/machines.ts +1 -1
- package/src/routes/scheduled-tasks.ts +39 -14
- package/src/routes/sessions.ts +77 -12
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +124 -7
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/viewer.ts +29 -20
- package/dist/chunk-HWXJW5C7.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
EditSessionQueueItemRequest,
|
|
13
13
|
EndSessionRealtimeRequest,
|
|
14
14
|
FsDeleteRequest,
|
|
15
|
+
FsListBatchRequest,
|
|
15
16
|
FsListRequest,
|
|
16
17
|
FsMkdirRequest,
|
|
17
18
|
FsMoveRequest,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
FsWriteRequest,
|
|
20
21
|
HumanInputRequestStatus,
|
|
21
22
|
GitDiffRequest,
|
|
23
|
+
GitReadBatchRequest,
|
|
22
24
|
GitLogRequest,
|
|
23
25
|
GitShowRequest,
|
|
24
26
|
GitStatusRequest,
|
|
@@ -148,6 +150,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
148
150
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
149
151
|
import {
|
|
150
152
|
requireAccessGrant,
|
|
153
|
+
requirePermission,
|
|
151
154
|
requireSessionAuthorization,
|
|
152
155
|
requireSessionAuthorizationListScope,
|
|
153
156
|
SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
|
|
@@ -333,6 +336,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
333
336
|
const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
|
|
334
337
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
335
338
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
339
|
+
// Reject malformed route identifiers before the authorization resolver
|
|
340
|
+
// reaches UUID-typed persistence queries. Besides avoiding a needless DB
|
|
341
|
+
// round trip, this preserves the session surface's non-enumerating 404
|
|
342
|
+
// contract instead of leaking a driver-level 500.
|
|
343
|
+
if (!z.string().uuid().safeParse(sessionId).success) {
|
|
344
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
345
|
+
}
|
|
336
346
|
const operation = sessionAuthorizationOperationForHttp(
|
|
337
347
|
c.req.method,
|
|
338
348
|
new URL(c.req.url).pathname,
|
|
@@ -1342,11 +1352,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1342
1352
|
if (event) {
|
|
1343
1353
|
try {
|
|
1344
1354
|
await bus.publish(workspaceId, sessionId, [event]);
|
|
1345
|
-
} catch
|
|
1346
|
-
console.warn(
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1355
|
+
} catch {
|
|
1356
|
+
console.warn("[api] cleared-goal live publish failed; durable event reconciles on replay", {
|
|
1357
|
+
errorClass: "EventPublishOperationError",
|
|
1358
|
+
errorCode: "cleared_goal_live_publish_failed",
|
|
1359
|
+
origin: "api",
|
|
1360
|
+
});
|
|
1350
1361
|
}
|
|
1351
1362
|
}
|
|
1352
1363
|
return c.body(null, 204);
|
|
@@ -2190,6 +2201,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2190
2201
|
: {}),
|
|
2191
2202
|
});
|
|
2192
2203
|
|
|
2204
|
+
const repositoryRoots = [
|
|
2205
|
+
...new Set(
|
|
2206
|
+
session.resources.flatMap((resource) =>
|
|
2207
|
+
resource.kind === "repository" && typeof resource.mountPath === "string"
|
|
2208
|
+
? [resource.mountPath.replace(/^\/+|\/+$/g, "")]
|
|
2209
|
+
: [],
|
|
2210
|
+
),
|
|
2211
|
+
),
|
|
2212
|
+
].filter(Boolean);
|
|
2213
|
+
|
|
2193
2214
|
// SWAP-CASE desktop transport (BOTH directions): negotiateCapabilities keyed on
|
|
2194
2215
|
// the HOME backend, but the pixel plane actually runs on the ACTIVE sandbox — and
|
|
2195
2216
|
// the two backends use DIFFERENT wire transports. The advertised transport MUST
|
|
@@ -2209,7 +2230,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2209
2230
|
// active sandbox kind is "selfhosted") — EXACTLY mintDesktopStream's routing. When
|
|
2210
2231
|
// the desktop is available we set the transport from the ACTIVE sandbox in one
|
|
2211
2232
|
// place (resolveActiveDesktopTransport), covering BOTH swap directions.
|
|
2212
|
-
let responseCapabilities =
|
|
2233
|
+
let responseCapabilities = {
|
|
2234
|
+
...capabilities,
|
|
2235
|
+
Git: {
|
|
2236
|
+
...capabilities.Git,
|
|
2237
|
+
repos: capabilities.Git.available ? repositoryRoots : [],
|
|
2238
|
+
},
|
|
2239
|
+
};
|
|
2213
2240
|
if (capabilities.DesktopStream.transport !== null) {
|
|
2214
2241
|
const activeSandbox = session.activeSandboxId
|
|
2215
2242
|
? await getSandbox(db, workspaceId, session.activeSandboxId)
|
|
@@ -2219,7 +2246,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2219
2246
|
settings.sandboxDesktopInteractive !== false,
|
|
2220
2247
|
);
|
|
2221
2248
|
responseCapabilities = {
|
|
2222
|
-
...
|
|
2249
|
+
...responseCapabilities,
|
|
2223
2250
|
DesktopStream: { ...capabilities.DesktopStream, ...wire },
|
|
2224
2251
|
};
|
|
2225
2252
|
}
|
|
@@ -2273,7 +2300,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2273
2300
|
// when cold).
|
|
2274
2301
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers", async (c) => {
|
|
2275
2302
|
const workspaceId = c.req.param("workspaceId");
|
|
2276
|
-
|
|
2303
|
+
// Authenticate and bind the workspace before parsing. The requested plane
|
|
2304
|
+
// determines the narrower permission below: terminal-only holders must not
|
|
2305
|
+
// require the strictly broader un-redacted Desktop permission.
|
|
2306
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
2277
2307
|
assertOwnershipEnabled();
|
|
2278
2308
|
const sessionId = c.req.param("sessionId");
|
|
2279
2309
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2297,6 +2327,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2297
2327
|
// read-only sse-events firehose forever ("read only"), and with the desktop tier
|
|
2298
2328
|
// off by default there was no consent flow to ever clear the gate.
|
|
2299
2329
|
const wantDesktop = parsed.data.desktop ?? false;
|
|
2330
|
+
requirePermission(grant, wantDesktop ? "stream:view" : "terminal:attach");
|
|
2300
2331
|
const { shared } = await resolveSharedExposure(workspaceId, session);
|
|
2301
2332
|
if (wantDesktop) {
|
|
2302
2333
|
const ack = await getStreamAcknowledgment(db, {
|
|
@@ -2443,12 +2474,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2443
2474
|
});
|
|
2444
2475
|
|
|
2445
2476
|
// POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
|
|
2446
|
-
//
|
|
2477
|
+
// A holder can represent the terminal-only plane, so lifecycle control uses
|
|
2478
|
+
// terminal:attach. Desktop callers continue to pass via workspace:admin.
|
|
2447
2479
|
app.post(
|
|
2448
2480
|
"/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
|
|
2449
2481
|
async (c) => {
|
|
2450
2482
|
const workspaceId = c.req.param("workspaceId");
|
|
2451
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
2483
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
|
|
2452
2484
|
assertOwnershipEnabled();
|
|
2453
2485
|
const sessionId = c.req.param("sessionId");
|
|
2454
2486
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2478,7 +2510,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2478
2510
|
// DELETE .../viewers/:viewerId — release the holder (idempotent).
|
|
2479
2511
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
|
|
2480
2512
|
const workspaceId = c.req.param("workspaceId");
|
|
2481
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
2513
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
|
|
2482
2514
|
assertOwnershipEnabled();
|
|
2483
2515
|
const sessionId = c.req.param("sessionId");
|
|
2484
2516
|
const session = await getSession(db, workspaceId, sessionId);
|
|
@@ -2596,6 +2628,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2596
2628
|
return c.json(out);
|
|
2597
2629
|
});
|
|
2598
2630
|
|
|
2631
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
|
|
2632
|
+
const ctx = await channelAPreamble(c, "files:read");
|
|
2633
|
+
const req = await parseChannelABody(c, FsListBatchRequest);
|
|
2634
|
+
const out = await withChannelA(channelAServices, ctx, async ({ service }) => ({
|
|
2635
|
+
results: await Promise.all(
|
|
2636
|
+
req.requests.map((request) =>
|
|
2637
|
+
Promise.resolve().then(async () => await service.fsList(request)),
|
|
2638
|
+
),
|
|
2639
|
+
),
|
|
2640
|
+
}));
|
|
2641
|
+
return c.json(out);
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2599
2644
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
|
|
2600
2645
|
const ctx = await channelAPreamble(c, "files:read");
|
|
2601
2646
|
const req = await parseChannelABody(c, FsReadRequest);
|
|
@@ -2646,6 +2691,26 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2646
2691
|
return c.json(out);
|
|
2647
2692
|
});
|
|
2648
2693
|
|
|
2694
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
|
|
2695
|
+
const ctx = await channelAPreamble(c, "files:read");
|
|
2696
|
+
const req = await parseChannelABody(c, GitReadBatchRequest);
|
|
2697
|
+
const out = await withChannelA(channelAServices, ctx, async ({ service }) => ({
|
|
2698
|
+
results: await Promise.all(
|
|
2699
|
+
req.requests.map(async (request) => {
|
|
2700
|
+
const diffRequest = request.diff;
|
|
2701
|
+
const [status, diff] = await Promise.all([
|
|
2702
|
+
Promise.resolve().then(async () => await service.gitStatus(request.status)),
|
|
2703
|
+
diffRequest
|
|
2704
|
+
? Promise.resolve().then(async () => await service.gitDiff(diffRequest))
|
|
2705
|
+
: Promise.resolve(undefined),
|
|
2706
|
+
]);
|
|
2707
|
+
return { status, ...(diff ? { diff } : {}) };
|
|
2708
|
+
}),
|
|
2709
|
+
),
|
|
2710
|
+
}));
|
|
2711
|
+
return c.json(out);
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2649
2714
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
|
|
2650
2715
|
const ctx = await channelAPreamble(c, "files:read");
|
|
2651
2716
|
const req = await parseChannelABody(c, GitLogRequest);
|
|
@@ -3101,7 +3166,7 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
3101
3166
|
if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
|
|
3102
3167
|
return "session.viewer.control";
|
|
3103
3168
|
}
|
|
3104
|
-
if (suffix === "/fs/list" || suffix === "/fs/read") {
|
|
3169
|
+
if (suffix === "/fs/list" || suffix === "/fs/list-batch" || suffix === "/fs/read") {
|
|
3105
3170
|
return verb === "POST" ? "session.files.read" : null;
|
|
3106
3171
|
}
|
|
3107
3172
|
if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
|
|
@@ -35,14 +35,17 @@ import {
|
|
|
35
35
|
markTranscriptionRecordingObjectsCleaned,
|
|
36
36
|
reserveTranscriptionRecordingChunk,
|
|
37
37
|
reserveTranscriptionRecordingSegment,
|
|
38
|
+
runIdempotentPersistenceTransaction,
|
|
38
39
|
startTranscriptionRecordingSegmentProviderCall,
|
|
39
40
|
transcriptionRecordingObjectKeys,
|
|
40
41
|
TranscriptionRecordingConflictError,
|
|
41
42
|
TranscriptionRecordingNotFoundError,
|
|
42
43
|
TranscriptionRecordingStateError,
|
|
44
|
+
isSessionEventPersistenceError,
|
|
43
45
|
} from "@opengeni/db";
|
|
44
46
|
import { getWorkspace } from "@opengeni/db";
|
|
45
47
|
import type { Context, Hono } from "hono";
|
|
48
|
+
import { ApiHttpError } from "../http/api-error";
|
|
46
49
|
import { TranscriptionSegmenterError } from "../transcription/segmenter";
|
|
47
50
|
|
|
48
51
|
const CHUNK_SHA256_HEADER = "x-opengeni-chunk-sha256";
|
|
@@ -153,17 +156,26 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
|
|
|
153
156
|
) {
|
|
154
157
|
return c.json({ code: "not_supported" }, 415);
|
|
155
158
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
159
|
+
const persistenceCorrelationId = correlationId(c);
|
|
160
|
+
const reservation = await runIdempotentPersistenceTransaction(
|
|
161
|
+
{
|
|
162
|
+
stage: "transcription_recording_chunk_reservation",
|
|
163
|
+
eventTypes: ["transcription_recording_chunk"],
|
|
164
|
+
correlationId: persistenceCorrelationId,
|
|
165
|
+
},
|
|
166
|
+
() =>
|
|
167
|
+
reserveTranscriptionRecordingChunk(deps.db, {
|
|
168
|
+
...authority,
|
|
169
|
+
recordingId: existing.recording.id,
|
|
170
|
+
chunkNumber,
|
|
171
|
+
byteLength: body.byteLength,
|
|
172
|
+
sha256,
|
|
173
|
+
startMilliseconds,
|
|
174
|
+
durationMilliseconds,
|
|
175
|
+
maxTotalBytes: deps.settings.voiceInputResumableMaxSizeBytes,
|
|
176
|
+
maxDurationMilliseconds: deps.settings.voiceInputResumableMaxDurationSeconds * 1_000,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
167
179
|
if (!reservation.deduplicated) {
|
|
168
180
|
try {
|
|
169
181
|
// A concurrent same-hash retry may still observe the row while it is
|
|
@@ -180,12 +192,20 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
|
|
|
180
192
|
throw new RecordingProcessingError("Chunk upload failed", "network", true);
|
|
181
193
|
}
|
|
182
194
|
}
|
|
183
|
-
const completed = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
195
|
+
const completed = await runIdempotentPersistenceTransaction(
|
|
196
|
+
{
|
|
197
|
+
stage: "transcription_recording_chunk_completion",
|
|
198
|
+
eventTypes: ["transcription_recording_chunk"],
|
|
199
|
+
correlationId: persistenceCorrelationId,
|
|
200
|
+
},
|
|
201
|
+
() =>
|
|
202
|
+
completeTranscriptionRecordingChunk(deps.db, {
|
|
203
|
+
workspaceId: authority.workspaceId,
|
|
204
|
+
subjectId: authority.subjectId,
|
|
205
|
+
recordingId: existing.recording.id,
|
|
206
|
+
chunkNumber,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
189
209
|
const response: UploadTranscriptionRecordingChunkResponse = {
|
|
190
210
|
recording: completed.recording.recording,
|
|
191
211
|
chunk: {
|
|
@@ -620,9 +640,25 @@ function routeError(c: Context, error: unknown): Response | Promise<Response> {
|
|
|
620
640
|
: error.code === "unavailable"
|
|
621
641
|
? 503
|
|
622
642
|
: 502;
|
|
643
|
+
if (error.retryable) {
|
|
644
|
+
throw new ApiHttpError(status, {
|
|
645
|
+
code: "upstream_unavailable",
|
|
646
|
+
message: "Transcription is temporarily unavailable.",
|
|
647
|
+
retryable: true,
|
|
648
|
+
details: { transcriptionCode: error.code },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
623
651
|
return c.json({ code: error.code }, status as never);
|
|
624
652
|
}
|
|
625
|
-
|
|
653
|
+
if (isSessionEventPersistenceError(error)) {
|
|
654
|
+
throw new ApiHttpError(503, {
|
|
655
|
+
code: "upstream_unavailable",
|
|
656
|
+
message: "Transcription is temporarily unavailable.",
|
|
657
|
+
retryable: true,
|
|
658
|
+
details: { persistenceCode: error.code },
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
throw error;
|
|
626
662
|
}
|
|
627
663
|
|
|
628
664
|
async function jsonBody(c: Context): Promise<unknown> {
|
|
@@ -688,22 +724,18 @@ async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint
|
|
|
688
724
|
const reader = request.body.getReader();
|
|
689
725
|
const chunks: Uint8Array[] = [];
|
|
690
726
|
let total = 0;
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
702
|
-
}
|
|
703
|
-
chunks.push(next.value);
|
|
727
|
+
for (;;) {
|
|
728
|
+
if (request.signal.aborted) {
|
|
729
|
+
throw new RecordingProcessingError("Chunk upload was cancelled", "cancelled", true);
|
|
730
|
+
}
|
|
731
|
+
const next = await reader.read();
|
|
732
|
+
if (next.done) break;
|
|
733
|
+
total += next.value.byteLength;
|
|
734
|
+
if (total > maxBytes) {
|
|
735
|
+
await reader.cancel();
|
|
736
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
704
737
|
}
|
|
705
|
-
|
|
706
|
-
reader.releaseLock();
|
|
738
|
+
chunks.push(next.value);
|
|
707
739
|
}
|
|
708
740
|
if (total === 0) {
|
|
709
741
|
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// to, the `server_id` for the response `aud`, and the presented `auth_token`);
|
|
11
11
|
// 2. VALIDATES the bearer with verifyEnrollmentBearer (HMAC, via
|
|
12
12
|
// resolveEnrollmentSigningSecret) — an invalid/expired/forged bearer is denied;
|
|
13
|
-
// 3. confirms the enrollment is still ACTIVE in the DB
|
|
14
|
-
//
|
|
13
|
+
// 3. confirms the enrollment is still ACTIVE in the DB at the exact credential
|
|
14
|
+
// generation (a revoked or re-enrolled machine denies an old bearer);
|
|
15
15
|
// 4. signs a NATS user JWT granting pub/sub ONLY `agent.<ws>.>` + `_INBOX.>`
|
|
16
16
|
// (deny-all-else by an allow-list) and returns it inside a signed
|
|
17
17
|
// authorization-response JWT.
|
|
@@ -48,6 +48,8 @@ import { observabilityEventLogger } from "../observability";
|
|
|
48
48
|
|
|
49
49
|
/** The NATS subject nats-server publishes authorization requests on (ADR-26). */
|
|
50
50
|
export const AUTH_CALLOUT_SUBJECT = "$SYS.REQ.USER.AUTH";
|
|
51
|
+
/** Keep live NATS credentials short-lived while never outliving the bearer. */
|
|
52
|
+
export const NATS_USER_JWT_TTL_SECONDS = 5 * 60;
|
|
51
53
|
|
|
52
54
|
export interface AuthCalloutDeps {
|
|
53
55
|
db: Database;
|
|
@@ -123,13 +125,23 @@ export async function handleAuthorizationRequest(
|
|
|
123
125
|
// Belt-and-braces: the bearer's agentId/enrollmentId must match the row we found.
|
|
124
126
|
// (verifyEnrollmentBearer already binds them; this guards a future schema where
|
|
125
127
|
// agentId != enrollmentId.)
|
|
126
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
enrollment.workspaceId !== claims.workspaceId ||
|
|
130
|
+
enrollment.id !== claims.enrollmentId ||
|
|
131
|
+
enrollment.id !== claims.agentId ||
|
|
132
|
+
claims.agentId !== claims.enrollmentId ||
|
|
133
|
+
claims.subjectPrefix !== `agent.${claims.workspaceId}.${claims.agentId}`
|
|
134
|
+
) {
|
|
127
135
|
return deny("enrollment identity mismatch");
|
|
128
136
|
}
|
|
137
|
+
if (enrollment.credentialGeneration !== claims.credentialGeneration) {
|
|
138
|
+
return deny("enrollment credential generation mismatch");
|
|
139
|
+
}
|
|
129
140
|
|
|
130
141
|
// GRANT: a user JWT scoped to ONLY this workspace's agent subtree + the reply
|
|
131
142
|
// inbox. This allow-list IS the per-workspace isolation boundary.
|
|
132
143
|
const permissions = workspaceAgentPermissions(claims.workspaceId);
|
|
144
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
133
145
|
const userJwt = mintUserJwt({
|
|
134
146
|
userPublicKey: decoded.userNkey,
|
|
135
147
|
accountSeed: deps.callout.accountSeed,
|
|
@@ -142,7 +154,7 @@ export async function handleAuthorizationRequest(
|
|
|
142
154
|
audienceAccount: deps.callout.accountName,
|
|
143
155
|
// Tie the credential's life to the bearer's remaining life: a revoked/expired
|
|
144
156
|
// enrollment cannot outlive its bearer at the NATS layer either.
|
|
145
|
-
expiresAtSeconds: claims.exp,
|
|
157
|
+
expiresAtSeconds: Math.min(claims.exp, nowSeconds + NATS_USER_JWT_TTL_SECONDS),
|
|
146
158
|
});
|
|
147
159
|
const response = mintAuthResponse({
|
|
148
160
|
userPublicKey: decoded.userNkey,
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type { Session } from "@opengeni/contracts";
|
|
|
30
30
|
import {
|
|
31
31
|
acquireLease,
|
|
32
32
|
getSandboxSessionEnvelope,
|
|
33
|
+
getEnrollment,
|
|
33
34
|
getSandbox,
|
|
34
35
|
loadWorkspaceEnvironmentForRun,
|
|
35
36
|
markWarmLeaseInstanceLost,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
isProviderSandboxNotFoundError,
|
|
50
51
|
SandboxChannelAService,
|
|
51
52
|
NatsControlRpc,
|
|
53
|
+
NatsOpStreamTransport,
|
|
52
54
|
ChannelAConflictError,
|
|
53
55
|
ChannelANotFoundError,
|
|
54
56
|
ChannelAUnsupportedError,
|
|
@@ -90,6 +92,97 @@ export type ChannelAHandle = {
|
|
|
90
92
|
requestId: string;
|
|
91
93
|
};
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Provider handles are lightweight references to a lease-owned sandbox, but
|
|
97
|
+
* reconstructing one is not free: Modal resume-by-id plus its first command can
|
|
98
|
+
* dominate a small Git/files read. Workspace panels issue several independent
|
|
99
|
+
* Channel-A requests together, so reuse the exact fenced handle briefly instead
|
|
100
|
+
* of making every request reattach to the same warm instance.
|
|
101
|
+
*
|
|
102
|
+
* The key includes the session, lease epoch, and immutable provider instance id.
|
|
103
|
+
* A rotation can therefore never inherit an old handle. Entries are bounded and
|
|
104
|
+
* expire opportunistically; eviction only drops local references and never
|
|
105
|
+
* terminates the lease-owned sandbox.
|
|
106
|
+
*/
|
|
107
|
+
const CHANNEL_A_HANDLE_CACHE_TTL_MS = 300_000;
|
|
108
|
+
const CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES = 64;
|
|
109
|
+
type CachedEstablishedHandle = {
|
|
110
|
+
promise: Promise<EstablishedSandboxSession>;
|
|
111
|
+
lastUsedAt: number;
|
|
112
|
+
};
|
|
113
|
+
const establishedHandleCache = new Map<string, CachedEstablishedHandle>();
|
|
114
|
+
|
|
115
|
+
function establishedHandleCacheKey(
|
|
116
|
+
workspaceId: string,
|
|
117
|
+
sessionId: string,
|
|
118
|
+
lease: LeaseSnapshot,
|
|
119
|
+
): string {
|
|
120
|
+
return [workspaceId, sessionId, lease.leaseEpoch, lease.instanceId ?? ""].join("\u0000");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pruneEstablishedHandleCache(now: number): void {
|
|
124
|
+
for (const [key, entry] of establishedHandleCache) {
|
|
125
|
+
if (now - entry.lastUsedAt > CHANNEL_A_HANDLE_CACHE_TTL_MS) {
|
|
126
|
+
establishedHandleCache.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
while (establishedHandleCache.size >= CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES) {
|
|
130
|
+
const oldestKey = establishedHandleCache.keys().next().value as string | undefined;
|
|
131
|
+
if (oldestKey === undefined) break;
|
|
132
|
+
establishedHandleCache.delete(oldestKey);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function establishCachedHandle(
|
|
137
|
+
key: string,
|
|
138
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
139
|
+
): Promise<EstablishedSandboxSession> {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
pruneEstablishedHandleCache(now);
|
|
142
|
+
const cached = establishedHandleCache.get(key);
|
|
143
|
+
if (cached) {
|
|
144
|
+
cached.lastUsedAt = now;
|
|
145
|
+
// Refresh insertion order so the bounded map evicts the least-recently used
|
|
146
|
+
// exact lease identity first.
|
|
147
|
+
establishedHandleCache.delete(key);
|
|
148
|
+
establishedHandleCache.set(key, cached);
|
|
149
|
+
return await cached.promise;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const promise = establish();
|
|
153
|
+
const entry: CachedEstablishedHandle = { promise, lastUsedAt: now };
|
|
154
|
+
establishedHandleCache.set(key, entry);
|
|
155
|
+
try {
|
|
156
|
+
return await promise;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (establishedHandleCache.get(key) === entry) establishedHandleCache.delete(key);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reuse the exact lease-fenced provider handle across API-direct surfaces.
|
|
164
|
+
* Stream capability negotiation and the first Files/Changes reads commonly run
|
|
165
|
+
* back-to-back; sharing this handle avoids paying the same Modal resume twice. */
|
|
166
|
+
export async function establishCachedChannelAHandle(
|
|
167
|
+
workspaceId: string,
|
|
168
|
+
sessionId: string,
|
|
169
|
+
lease: LeaseSnapshot,
|
|
170
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
171
|
+
): Promise<EstablishedSandboxSession> {
|
|
172
|
+
return await establishCachedHandle(
|
|
173
|
+
establishedHandleCacheKey(workspaceId, sessionId, lease),
|
|
174
|
+
establish,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function rememberEstablishedHandle(key: string, established: EstablishedSandboxSession): void {
|
|
179
|
+
pruneEstablishedHandleCache(Date.now());
|
|
180
|
+
establishedHandleCache.set(key, {
|
|
181
|
+
promise: Promise.resolve(established),
|
|
182
|
+
lastUsedAt: Date.now(),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
93
186
|
/**
|
|
94
187
|
* Run a Channel-A op against a live box, API-direct. Acquires an exact direct holder
|
|
95
188
|
* (warming the box when cold), resumes by id, builds the service, runs `fn`, and
|
|
@@ -169,7 +262,12 @@ export async function withChannelA<T>(
|
|
|
169
262
|
leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
|
|
170
263
|
emit,
|
|
171
264
|
});
|
|
172
|
-
|
|
265
|
+
const result = await fn({ service, lease, routingSession, requestId });
|
|
266
|
+
// The direct request has accepted the result in memory. Finalize every
|
|
267
|
+
// Connected Machine backend the routing proxy reached so a mid-request
|
|
268
|
+
// route transition cannot leave completed output retained until TTL.
|
|
269
|
+
await routingSession.finalizeOpStreamOps().catch(() => undefined);
|
|
270
|
+
return result;
|
|
173
271
|
};
|
|
174
272
|
|
|
175
273
|
// A machine-targeted top-level session has an honest selfhosted HOME label.
|
|
@@ -190,6 +288,7 @@ export async function withChannelA<T>(
|
|
|
190
288
|
message: "machine-home session points to an unavailable Connected Machine",
|
|
191
289
|
});
|
|
192
290
|
}
|
|
291
|
+
const enrollment = await getEnrollment(db, workspaceId, sandbox.enrollmentId);
|
|
193
292
|
const built = await buildSelfhostedBackendSession({
|
|
194
293
|
workspaceId,
|
|
195
294
|
agentId: sandbox.enrollmentId,
|
|
@@ -200,6 +299,17 @@ export async function withChannelA<T>(
|
|
|
200
299
|
workingDir: pointer.workingDir,
|
|
201
300
|
timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
|
|
202
301
|
execTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
|
|
302
|
+
...(settings.agentOpStreamEnabled === true &&
|
|
303
|
+
enrollment?.opStream === true &&
|
|
304
|
+
bus.getOpStreamConnection
|
|
305
|
+
? {
|
|
306
|
+
opStream: {
|
|
307
|
+
transport: new NatsOpStreamTransport(
|
|
308
|
+
async () => bus.getOpStreamConnection?.() ?? null,
|
|
309
|
+
),
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
: {}),
|
|
203
313
|
});
|
|
204
314
|
established = {
|
|
205
315
|
client: built.client,
|
|
@@ -271,6 +381,7 @@ export async function withChannelA<T>(
|
|
|
271
381
|
|
|
272
382
|
let established: EstablishedSandboxSession | undefined;
|
|
273
383
|
let leaseSnapshot: LeaseSnapshot = acquired.lease;
|
|
384
|
+
let establishedCacheKey: string | null = null;
|
|
274
385
|
|
|
275
386
|
try {
|
|
276
387
|
const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
|
|
@@ -303,6 +414,8 @@ export async function withChannelA<T>(
|
|
|
303
414
|
});
|
|
304
415
|
established = result.established;
|
|
305
416
|
leaseSnapshot = result.lease;
|
|
417
|
+
establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, leaseSnapshot);
|
|
418
|
+
rememberEstablishedHandle(establishedCacheKey, established);
|
|
306
419
|
} catch (error) {
|
|
307
420
|
throw new HTTPException(409, {
|
|
308
421
|
message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
|
|
@@ -323,17 +436,21 @@ export async function withChannelA<T>(
|
|
|
323
436
|
});
|
|
324
437
|
}
|
|
325
438
|
leaseSnapshot = live;
|
|
439
|
+
establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, live);
|
|
326
440
|
try {
|
|
327
|
-
established = await
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
441
|
+
established = await establishCachedChannelAHandle(workspaceId, session.id, live, () =>
|
|
442
|
+
establishSandboxSessionFromEnvelope(settings, live.resumeState, {
|
|
443
|
+
sessionId: session.id,
|
|
444
|
+
recovery: "resume-only",
|
|
445
|
+
backendOverride: session.sandboxBackend,
|
|
446
|
+
environment,
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
333
449
|
} catch (error) {
|
|
334
450
|
if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) {
|
|
335
451
|
throw error;
|
|
336
452
|
}
|
|
453
|
+
establishedHandleCache.delete(establishedCacheKey);
|
|
337
454
|
const marked = await markWarmLeaseInstanceLost(db, {
|
|
338
455
|
accountId,
|
|
339
456
|
workspaceId,
|
|
@@ -75,8 +75,9 @@ export const DEVICE_POLL_INTERVAL_SECONDS = 5;
|
|
|
75
75
|
// box) caused a self-hosted agent to drop PERMANENTLY one hour after connecting: the
|
|
76
76
|
// bearer expired and the auth-callout rejected every reconnect ("re-enroll may be
|
|
77
77
|
// required"). A long-lived bearer is safe because the auth-callout RE-CHECKS the
|
|
78
|
-
// enrollment status on every (re)connect
|
|
79
|
-
//
|
|
78
|
+
// enrollment status AND credential generation on every (re)connect
|
|
79
|
+
// (auth-callout.ts) — a revoked machine or an old pre-re-enrollment bearer is denied
|
|
80
|
+
// regardless of bearer life. The short NATS user-JWT cap bounds already-live access.
|
|
80
81
|
export const ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
|
|
81
82
|
// The relay PRODUCER token (the `ogr_` token; M8b) is ENROLLMENT-scoped,
|
|
82
83
|
// NOT per-stream: the agent presents it on every channel registration for the life
|
|
@@ -370,6 +371,7 @@ export async function exchangeEnrollToken(
|
|
|
370
371
|
secret,
|
|
371
372
|
workspaceId: claims.workspaceId,
|
|
372
373
|
agentId: enrollment.id,
|
|
374
|
+
credentialGeneration: enrollment.credentialGeneration,
|
|
373
375
|
consentedScreenControl: enrollment.allowScreenControl,
|
|
374
376
|
});
|
|
375
377
|
return { ok: true, credentials };
|
|
@@ -430,6 +432,7 @@ export async function pollDeviceEnrollment(
|
|
|
430
432
|
secret,
|
|
431
433
|
workspaceId: request.workspaceId,
|
|
432
434
|
agentId: enrollment.id,
|
|
435
|
+
credentialGeneration: enrollment.credentialGeneration,
|
|
433
436
|
consentedScreenControl: enrollment.allowScreenControl,
|
|
434
437
|
});
|
|
435
438
|
|
|
@@ -455,7 +458,13 @@ export async function pollDeviceEnrollment(
|
|
|
455
458
|
* bearer so an agent using it as the connect-token credential works uniformly. */
|
|
456
459
|
async function buildEnrollmentCredentials(
|
|
457
460
|
services: EnrollmentServices,
|
|
458
|
-
input: {
|
|
461
|
+
input: {
|
|
462
|
+
secret: string;
|
|
463
|
+
workspaceId: string;
|
|
464
|
+
agentId: string;
|
|
465
|
+
credentialGeneration: number;
|
|
466
|
+
consentedScreenControl: boolean;
|
|
467
|
+
},
|
|
459
468
|
): Promise<EnrollmentCredentialsResponse> {
|
|
460
469
|
const { settings } = services;
|
|
461
470
|
// The control-plane subject prefix the agent subscribes to: agent.<ws>.<id>.
|
|
@@ -466,6 +475,7 @@ async function buildEnrollmentCredentials(
|
|
|
466
475
|
workspaceId: input.workspaceId,
|
|
467
476
|
agentId: input.agentId,
|
|
468
477
|
enrollmentId: input.agentId,
|
|
478
|
+
credentialGeneration: input.credentialGeneration,
|
|
469
479
|
subjectPrefix,
|
|
470
480
|
exp,
|
|
471
481
|
});
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -219,7 +219,7 @@ export async function listMachines(
|
|
|
219
219
|
// onto the machines (no N+1). Each machine is probed for liveness.
|
|
220
220
|
const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
|
|
221
221
|
listSandboxes(db, workspaceId),
|
|
222
|
-
listEnrollments(db, workspaceId),
|
|
222
|
+
listEnrollments(db, workspaceId, { status: "active" }),
|
|
223
223
|
readMachineMetricsLatestForWorkspace(db, workspaceId),
|
|
224
224
|
]);
|
|
225
225
|
const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
|