@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.
Files changed (44) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +0 -30
  3. package/dist/{chunk-HWXJW5C7.js → chunk-T4T2PGU4.js} +3036 -1362
  4. package/dist/chunk-T4T2PGU4.js.map +1 -0
  5. package/dist/http/sse.d.ts +2 -0
  6. package/dist/index.js +29 -9
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/oauth-client.d.ts +8 -0
  9. package/dist/integrations/slack-interactions.d.ts +7 -1
  10. package/dist/mcp/receipts.d.ts +28 -0
  11. package/dist/mcp/scheduled-task-view.d.ts +350 -0
  12. package/dist/mcp/toolspace.d.ts +9 -0
  13. package/dist/sandbox/auth-callout.d.ts +2 -0
  14. package/dist/sandbox/channel-a.d.ts +5 -1
  15. package/package.json +12 -12
  16. package/src/app.ts +3 -4
  17. package/src/auth/managed-auth.ts +0 -16
  18. package/src/http/sse.ts +101 -6
  19. package/src/index.ts +28 -3
  20. package/src/integrations/oauth-client.ts +36 -56
  21. package/src/integrations/slack-interactions.ts +123 -15
  22. package/src/mcp/documents.ts +42 -25
  23. package/src/mcp/receipts.ts +95 -0
  24. package/src/mcp/scheduled-task-view.ts +608 -0
  25. package/src/mcp/server.ts +812 -182
  26. package/src/mcp/toolspace.ts +75 -71
  27. package/src/observability.ts +3 -3
  28. package/src/routes/api-keys.ts +7 -1
  29. package/src/routes/codex.ts +7 -4
  30. package/src/routes/connections.ts +74 -3
  31. package/src/routes/enrollments.ts +54 -12
  32. package/src/routes/environments.ts +60 -11
  33. package/src/routes/files.ts +175 -65
  34. package/src/routes/install.ts +31 -1
  35. package/src/routes/machines.ts +1 -1
  36. package/src/routes/scheduled-tasks.ts +39 -14
  37. package/src/routes/sessions.ts +77 -12
  38. package/src/routes/transcription-recordings.ts +65 -33
  39. package/src/sandbox/auth-callout.ts +16 -4
  40. package/src/sandbox/channel-a.ts +124 -7
  41. package/src/sandbox/enrollment.ts +13 -3
  42. package/src/sandbox/machines.ts +1 -1
  43. package/src/sandbox/viewer.ts +29 -20
  44. package/dist/chunk-HWXJW5C7.js.map +0 -1
@@ -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 (error) {
1346
- console.warn(
1347
- `[api] live publish failed for cleared goal ${workspaceId}/${sessionId}; event is durable and reconciles on replay`,
1348
- error,
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 = capabilities;
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
- ...capabilities,
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
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
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
- // The desktop-stream lifecycle is gated on stream:view (the un-redacted plane).
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, "stream:view");
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, "stream:view");
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 reservation = await reserveTranscriptionRecordingChunk(deps.db, {
157
- ...authority,
158
- recordingId: existing.recording.id,
159
- chunkNumber,
160
- byteLength: body.byteLength,
161
- sha256,
162
- startMilliseconds,
163
- durationMilliseconds,
164
- maxTotalBytes: deps.settings.voiceInputResumableMaxSizeBytes,
165
- maxDurationMilliseconds: deps.settings.voiceInputResumableMaxDurationSeconds * 1_000,
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 completeTranscriptionRecordingChunk(deps.db, {
184
- workspaceId: authority.workspaceId,
185
- subjectId: authority.subjectId,
186
- recordingId: existing.recording.id,
187
- chunkNumber,
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
- return c.json({ code: "unknown" }, 500);
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
- try {
692
- for (;;) {
693
- if (request.signal.aborted) {
694
- throw new RecordingProcessingError("Chunk upload was cancelled", "cancelled", true);
695
- }
696
- const next = await reader.read();
697
- if (next.done) break;
698
- total += next.value.byteLength;
699
- if (total > maxBytes) {
700
- await reader.cancel();
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
- } finally {
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 (a revoked machine is
14
- // denied even with a still-unexpired bearer);
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 (enrollment.id !== claims.enrollmentId) {
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,
@@ -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
- return await fn({ service, lease, routingSession, requestId });
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 establishSandboxSessionFromEnvelope(settings, live.resumeState, {
328
- sessionId: session.id,
329
- recovery: "resume-only",
330
- backendOverride: session.sandboxBackend,
331
- environment,
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 (auth-callout.ts) — a revoked machine is
79
- // denied regardless of bearer life exactly as the long-lived relay token relies on.
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: { secret: string; workspaceId: string; agentId: string; consentedScreenControl: boolean },
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
  });
@@ -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]));