@opengeni/api-router 0.23.1 → 0.26.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 (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -55,9 +55,12 @@ import {
55
55
  ViewerHeartbeatRequest,
56
56
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
57
57
  workspaceControlUtf8Bytes,
58
+ type AccessGrant,
59
+ type AttachViewerResponse,
58
60
  type SandboxBackend,
59
61
  type LineageNode,
60
62
  type Session,
63
+ type AgentTopologyPageResponse,
61
64
  type ErrorCode,
62
65
  type SessionAuthorizationOperation,
63
66
  type SessionQueueSnapshot,
@@ -86,6 +89,8 @@ import {
86
89
  listSessionEventPage,
87
90
  listSessionHumanInputRequests,
88
91
  listSessionIdsInGroup,
92
+ listSessionDiscoverySummaries,
93
+ listSessionDiscoveryAncestorPaths,
89
94
  listSessionsForSubject,
90
95
  getLatestStartedSessionTurn,
91
96
  listSessionTurns,
@@ -93,8 +98,8 @@ import {
93
98
  projectSessionForRelatedAccess,
94
99
  recordStreamAcknowledgment,
95
100
  requestSessionCompaction,
96
- setSessionCodexPin,
97
- withCodexCapacityMutation,
101
+ setSessionCodexPinInTransaction,
102
+ withSessionCodexCapacityMutation,
98
103
  setSessionPin,
99
104
  SessionPinVersionConflictError,
100
105
  SessionPinAccessError,
@@ -124,6 +129,7 @@ import {
124
129
  sessionLatestWorkspaceCapture,
125
130
  renewSessionRealtimeInTransaction,
126
131
  syncSessionRealtimeLedgerInTransaction,
132
+ withWorkspaceSessionActivityRls,
127
133
  withWorkspaceRls,
128
134
  workspaceCaptureAtRevision,
129
135
  type AppendEventInput,
@@ -131,6 +137,8 @@ import {
131
137
  type SandboxPtyProcessIdentity,
132
138
  type SandboxRetainedProcess,
133
139
  type Database,
140
+ type SessionDiscoveryCursor,
141
+ type SessionDiscoveryAncestor,
134
142
  } from "@opengeni/db";
135
143
  import {
136
144
  appendAndPublishEvents,
@@ -143,12 +151,20 @@ import {
143
151
  GatewayRealtimeBrokerError,
144
152
  } from "../gateway-realtime";
145
153
  import { z, ZodError } from "zod";
146
- import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
154
+ import {
155
+ runConcurrentChannelAReads,
156
+ withChannelA,
157
+ withChannelARead,
158
+ type ChannelAContext,
159
+ type ChannelAHandle,
160
+ type ChannelAOperation,
161
+ } from "../sandbox/channel-a";
147
162
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
148
163
  import type { Context, Hono, MiddlewareHandler } from "hono";
149
164
  import { HTTPException } from "hono/http-exception";
150
165
  import type { ContentfulStatusCode } from "hono/utils/http-status";
151
166
  import {
167
+ hasPermission,
152
168
  requireAccessGrant,
153
169
  requirePermission,
154
170
  requireSessionAuthorization,
@@ -206,6 +222,19 @@ import {
206
222
 
207
223
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
208
224
 
225
+ const VIEWER_LIFECYCLE_PERMISSIONS = ["stream:view", "terminal:attach", "files:write"] as const;
226
+
227
+ function requireViewerLifecyclePermission(grant: AccessGrant): void {
228
+ if (
229
+ VIEWER_LIFECYCLE_PERMISSIONS.some((permission) => hasPermission(grant.permissions, permission))
230
+ ) {
231
+ return;
232
+ }
233
+ throw new HTTPException(403, {
234
+ message: `missing permission: ${VIEWER_LIFECYCLE_PERMISSIONS.join(" or ")}`,
235
+ });
236
+ }
237
+
209
238
  export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
210
239
  const { settings, db, bus, workflowClient, objectStorage } = deps;
211
240
  const channelAServices = { db, settings, bus, observability: deps.observability };
@@ -407,7 +436,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
407
436
  // Creation has committed by this point. Keep response projection outside
408
437
  // the create-rejection boundary so a post-commit policy read cannot be
409
438
  // misreported as though the session itself was rejected.
410
- return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
439
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
411
440
  });
412
441
 
413
442
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
@@ -511,7 +540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
511
540
  // body for older clients while still making its older-pin omission visible
512
541
  // to raw HTTP consumers without changing that response shape.
513
542
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
514
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
543
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
515
544
  const decorate = (session: Session): Session =>
516
545
  sessionWithEffectiveToolPolicy(
517
546
  session,
@@ -533,6 +562,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
533
562
  return c.json([...page.pinned, ...page.sessions].map(decorate));
534
563
  });
535
564
 
565
+ app.get("/v1/workspaces/:workspaceId/agent-topology", async (c) => {
566
+ const workspaceId = c.req.param("workspaceId");
567
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
568
+ let authorizationScope;
569
+ try {
570
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
571
+ } catch (error) {
572
+ throw sessionAuthorizationHttpError(error);
573
+ }
574
+ const query = agentTopologyQuery(c.req.query());
575
+ const page = await listSessionDiscoverySummaries(db, workspaceId, {
576
+ limit: query.limit,
577
+ orderBy: "updatedAt",
578
+ subjectId: grant.subjectId,
579
+ ...(query.cursor ? { cursor: query.cursor } : {}),
580
+ ...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
581
+ ...(authorizationScope ? { authorizationScope } : {}),
582
+ });
583
+ const ancestorPaths = query.search
584
+ ? await listSessionDiscoveryAncestorPaths(
585
+ db,
586
+ workspaceId,
587
+ page.sessions.map((session) => session.id),
588
+ authorizationScope ?? undefined,
589
+ )
590
+ : new Map<string, SessionDiscoveryAncestor[]>();
591
+ const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
592
+ const blocker = session.effectiveControl.primaryBlocker;
593
+ return {
594
+ id: session.id,
595
+ title: session.title,
596
+ titleTruncated:
597
+ session.titleOriginalChars !== null &&
598
+ session.titleOriginalChars > Array.from(session.title ?? "").length,
599
+ parentSessionId: session.parentSessionId,
600
+ rootSessionId: session.rootSessionId,
601
+ nestedAgentDepth: session.nestedAgentDepth,
602
+ ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
603
+ id: ancestor.id,
604
+ title: ancestor.title,
605
+ titleTruncated:
606
+ ancestor.titleOriginalChars !== null &&
607
+ ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
608
+ })),
609
+ status: session.status,
610
+ pause: {
611
+ state: session.effectiveControl.state,
612
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
613
+ source: blocker
614
+ ? {
615
+ kind: blocker.kind,
616
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
617
+ displayName: blocker.displayName,
618
+ displayNameTruncated:
619
+ blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
620
+ }
621
+ : null,
622
+ },
623
+ children: session.treeStats,
624
+ createdAt: session.createdAt,
625
+ updatedAt: session.updatedAt,
626
+ };
627
+ });
628
+ return c.json({
629
+ sessions,
630
+ total: page.total,
631
+ hasMore: page.hasMore,
632
+ nextCursor: page.nextCursor
633
+ ? encodeAgentTopologyCursor({
634
+ cursor: page.nextCursor,
635
+ parentSessionId: query.parentSessionId,
636
+ search: query.search ?? null,
637
+ })
638
+ : null,
639
+ } satisfies AgentTopologyPageResponse);
640
+ });
641
+
536
642
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
537
643
  const workspaceId = c.req.param("workspaceId");
538
644
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -550,7 +656,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
550
656
  if (!session) {
551
657
  throw new HTTPException(404, { message: "session not found" });
552
658
  }
553
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
659
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
554
660
  });
555
661
 
556
662
  const publishRealtimeMutation = async (
@@ -589,16 +695,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
589
695
  throw new HTTPException(400, { message: "invalid session realtime request" });
590
696
  }
591
697
  try {
592
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
593
- scopedDb.transaction(async (tx) =>
594
- beginSessionRealtimeInTransaction(tx as unknown as Database, {
595
- accountId: grant.accountId,
596
- workspaceId,
597
- sessionId,
598
- ownerSubjectId: grant.subjectId,
599
- ...parsed.data,
600
- }),
601
- ),
698
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
699
+ beginSessionRealtimeInTransaction(scopedDb, {
700
+ accountId: grant.accountId,
701
+ workspaceId,
702
+ sessionId,
703
+ ownerSubjectId: grant.subjectId,
704
+ ...parsed.data,
705
+ }),
602
706
  );
603
707
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
604
708
  c.header("cache-control", "private, no-store");
@@ -626,16 +730,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
626
730
  throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
627
731
  }
628
732
  try {
629
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
630
- scopedDb.transaction(async (tx) =>
631
- renewSessionRealtimeInTransaction(tx as unknown as Database, {
632
- workspaceId,
633
- sessionId,
634
- realtimeId,
635
- ownerSubjectId: grant.subjectId,
636
- ...parsed.data,
637
- }),
638
- ),
733
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
734
+ renewSessionRealtimeInTransaction(scopedDb, {
735
+ workspaceId,
736
+ sessionId,
737
+ realtimeId,
738
+ ownerSubjectId: grant.subjectId,
739
+ ...parsed.data,
740
+ }),
639
741
  );
640
742
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
641
743
  c.header("cache-control", "private, no-store");
@@ -662,16 +764,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
662
764
  throw new HTTPException(400, { message: "invalid realtime end request" });
663
765
  }
664
766
  try {
665
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
666
- scopedDb.transaction(async (tx) =>
667
- endSessionRealtimeInTransaction(tx as unknown as Database, {
668
- workspaceId,
669
- sessionId,
670
- realtimeId,
671
- ownerSubjectId: grant.subjectId,
672
- ...parsed.data,
673
- }),
674
- ),
767
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
768
+ endSessionRealtimeInTransaction(scopedDb, {
769
+ workspaceId,
770
+ sessionId,
771
+ realtimeId,
772
+ ownerSubjectId: grant.subjectId,
773
+ ...parsed.data,
774
+ }),
675
775
  );
676
776
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
677
777
  c.header("cache-control", "private, no-store");
@@ -1034,16 +1134,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1034
1134
  throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
1035
1135
  }
1036
1136
  try {
1037
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1038
- scopedDb.transaction(async (tx) =>
1039
- syncSessionRealtimeLedgerInTransaction(tx as unknown as Database, {
1040
- workspaceId,
1041
- sessionId,
1042
- realtimeId,
1043
- ownerSubjectId: grant.subjectId,
1044
- ...parsed.data,
1045
- }),
1046
- ),
1137
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1138
+ syncSessionRealtimeLedgerInTransaction(scopedDb, {
1139
+ workspaceId,
1140
+ sessionId,
1141
+ realtimeId,
1142
+ ownerSubjectId: grant.subjectId,
1143
+ ...parsed.data,
1144
+ }),
1047
1145
  );
1048
1146
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1049
1147
  c.header("cache-control", "private, no-store");
@@ -1082,6 +1180,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1082
1180
  await withEffectivePolicy(
1083
1181
  deps,
1084
1182
  workspaceId,
1183
+ grant.subjectId,
1085
1184
  projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
1086
1185
  ),
1087
1186
  );
@@ -1106,7 +1205,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1106
1205
  const workspaceId = c.req.param("workspaceId");
1107
1206
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1108
1207
  const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
1109
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
1208
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
1110
1209
  return c.json({
1111
1210
  ...lineage,
1112
1211
  ancestors: lineage.ancestors.map((session) =>
@@ -1148,11 +1247,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1148
1247
  }
1149
1248
  }
1150
1249
  const pinned = target === "auto" ? null : target;
1151
- const mutation = await withCodexCapacityMutation(
1250
+ const mutation = await withSessionCodexCapacityMutation(
1152
1251
  db,
1153
1252
  { workspaceId, reason: "codex_manual_session_pin_changed" },
1154
1253
  async (tx) => {
1155
- const changed = await setSessionCodexPin(tx, workspaceId, sessionId, pinned);
1254
+ const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
1156
1255
  return { result: changed, changed };
1157
1256
  },
1158
1257
  );
@@ -1208,7 +1307,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1208
1307
  if (!session) {
1209
1308
  throw new HTTPException(404, { message: "session not found" });
1210
1309
  }
1211
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1310
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1212
1311
  });
1213
1312
 
1214
1313
  app.patch(
@@ -1246,7 +1345,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1246
1345
  const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
1247
1346
  try {
1248
1347
  const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
1249
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1348
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1250
1349
  } catch (error) {
1251
1350
  if (error instanceof SessionToolPolicyVersionConflictError) {
1252
1351
  return c.json(
@@ -1563,6 +1662,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1563
1662
  const projected = compact ? coalesceSessionEventDeltas(events) : events;
1564
1663
  const page = boundSessionEventHttpPage(projected, {
1565
1664
  direction,
1665
+ eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
1566
1666
  });
1567
1667
  const hasMore = dbPage.hasMore || page.truncated;
1568
1668
  c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
@@ -1845,10 +1945,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1845
1945
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1846
1946
  const sessionId = c.req.param("sessionId");
1847
1947
  await assertSessionExists(db, workspaceId, sessionId);
1848
- const raw = await c.req.json();
1849
- const payload = SteerSessionMessageRequest.parse(raw);
1948
+ const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
1850
1949
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1851
1950
  text: payload.text,
1951
+ annotations: payload.annotations,
1852
1952
  turnInstructions: payload.turnInstructions ?? null,
1853
1953
  resources: payload.resources,
1854
1954
  model: payload.model ?? null,
@@ -1870,8 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1870
1970
  const workspaceId = c.req.param("workspaceId");
1871
1971
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1872
1972
  const sessionId = c.req.param("sessionId");
1873
- const rawEvent = await c.req.json();
1874
- const event = ClientSessionEvent.parse(rawEvent);
1973
+ const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
1875
1974
  const refinedOperation =
1876
1975
  event.type === "user.approvalDecision"
1877
1976
  ? "session.approval.write"
@@ -1892,6 +1991,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1892
1991
  if (event.type === "user.message") {
1893
1992
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1894
1993
  text: event.payload.text,
1994
+ annotations: event.payload.annotations,
1895
1995
  turnInstructions: event.payload.turnInstructions ?? null,
1896
1996
  resources: event.payload.resources ?? [],
1897
1997
  model: event.payload.model ?? null,
@@ -2056,8 +2156,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2056
2156
  // GET .../stream-capabilities — the capability-negotiation read. Returns the
2057
2157
  // SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
2058
2158
  // shared-exposure disclosure + the calling principal's acknowledgment state),
2059
- // API-direct. The desktop URL/token stay null until P4 mints them (gated by
2060
- // liveness=cold until a box is warm); the read is non-mutating.
2159
+ // API-direct. It is a pure descriptor read: every URL/token stays null until an
2160
+ // exact, permission-checked POST /viewers grant mints it just in time.
2061
2161
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
2062
2162
  const workspaceId = c.req.param("workspaceId");
2063
2163
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2074,12 +2174,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2074
2174
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
2075
2175
  const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
2076
2176
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
2077
- // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
2078
- // ADDITIONALLY requires the shared-exposure ack. Both must match the POST
2079
- // /viewers gate EXACTLY otherwise a principal who recorded shared consent
2080
- // WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
2081
- // from this read path while being correctly 409'd on attach (a consent-gate
2082
- // bypass of the un-redacted pixel plane).
2177
+ // Surface consent state so the UI can decide whether an explicit desktop
2178
+ // grant may be requested. This descriptor read never mints a credential; the
2179
+ // POST /viewers grant below re-checks both consent bits and stream:view.
2083
2180
  const ack = await getStreamAcknowledgment(db, {
2084
2181
  workspaceId,
2085
2182
  sandboxGroupId: session.sandboxGroupId,
@@ -2089,65 +2186,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2089
2186
  ? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
2090
2187
  : false;
2091
2188
 
2092
- // P4.2 the pixel DATA PLANE, served API-direct. When the backend is
2093
- // desktop-capable AND sandboxDesktopEnabled AND the (shared, if shared)
2094
- // acknowledgment is present AND the box is WARM, mint the REAL DesktopStream
2095
- // cell IN-PROCESS: resume the box by id, ensureDisplayStack (idempotent),
2096
- // exposeStreamPort (resolve the 6080 tunnel + mint the scoped token), record
2097
- // data_plane_url under the epoch fence, and emit stream.url.rotated to other
2098
- // viewers on a box rollover. The handshake never SPINS UP a cold box (that is
2099
- // the viewer-attach path) — a cold lease stays lease_cold. A degraded mint
2100
- // (no secret / display-stack or tunnel failure) returns null → transport:null.
2101
- let desktopStream: DesktopStreamMint | null = null;
2102
- const desktopUnlocked =
2103
- settings.sandboxDesktopEnabled &&
2104
- !streamTokenDegraded(settings) &&
2105
- acknowledged &&
2106
- (session.activeSandboxId != null ||
2107
- lease?.liveness === "warm" ||
2108
- lease?.liveness === "draining");
2109
- if (desktopUnlocked) {
2110
- desktopStream = await mintDesktopStream(
2111
- { db, settings, bus },
2112
- {
2113
- accountId: grant.accountId,
2114
- workspaceId,
2115
- session,
2116
- // The handshake's token is scoped to the calling principal (it is a read,
2117
- // not a viewer-holder acquire); the per-holder token is re-minted on
2118
- // POST /viewers. A previousEpoch != current would have rotated already
2119
- // via the warming-commit; the read does not itself drive rotation.
2120
- viewerId: grant.subjectId,
2121
- ...(lease ? { lease } : {}),
2122
- },
2123
- );
2124
- }
2125
-
2126
- // P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
2127
- // desktop: it gates ONLY on sandboxTerminalEnabled + a real-PTY backend + a
2128
- // WARM box (NO un-redacted ack — the terminal cell has no acknowledgment
2129
- // gate). A degraded mint (terminal off / no secret / ttyd or tunnel failure)
2130
- // returns null → the Terminal cell falls back to the sse-events firehose.
2131
- let terminalStream: TerminalStreamMint | null = null;
2132
- const terminalUnlocked =
2133
- settings.sandboxTerminalEnabled &&
2134
- !streamTokenDegraded(settings) &&
2135
- (session.activeSandboxId != null ||
2136
- lease?.liveness === "warm" ||
2137
- lease?.liveness === "draining");
2138
- if (terminalUnlocked) {
2139
- terminalStream = await mintTerminalStream(
2140
- { db, settings, bus },
2141
- {
2142
- accountId: grant.accountId,
2143
- workspaceId,
2144
- session,
2145
- viewerId: grant.subjectId,
2146
- ...(lease ? { lease } : {}),
2147
- },
2148
- );
2149
- }
2150
-
2189
+ // This GET is deliberately descriptor-only: no provider resume, display/ttyd
2190
+ // startup, port exposure, or short-lived bearer mint. Besides enforcing least
2191
+ // privilege, that keeps the 120-second stream credentials from aging before a
2192
+ // user opens their surface. POST /viewers is the sole credential grant.
2151
2193
  const capabilities = negotiateCapabilities({
2152
2194
  sessionId,
2153
2195
  backend: session.sandboxBackend as SandboxBackend,
@@ -2174,31 +2216,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2174
2216
  desktopAcknowledged: acknowledged,
2175
2217
  shared,
2176
2218
  sharedSessionIds: visibleSharedSessionIds,
2177
- // The minted live address (null when not unlocked/degraded). The resolver
2178
- // only folds it in when the desktop gates pass + the ack is present.
2179
- ...(desktopStream
2180
- ? {
2181
- desktopStream: {
2182
- url: desktopStream.url,
2183
- token: desktopStream.token,
2184
- expiresAt: desktopStream.expiresAt,
2185
- resolution: desktopStream.resolution,
2186
- },
2187
- }
2188
- : {}),
2189
- // P5.t — the terminal policy toggle + the minted pty-ws address. The
2190
- // resolver advertises sse-events (firehose) on a cold/disabled terminal and
2191
- // folds the live pty-ws url/token in only when the gates passed + minted.
2219
+ // Plane policy only. Live addresses are intentionally absent on a
2220
+ // descriptor read and arrive from an authorized viewer grant.
2192
2221
  terminalEnabled: settings.sandboxTerminalEnabled,
2193
- ...(terminalStream
2194
- ? {
2195
- terminalStream: {
2196
- url: terminalStream.url,
2197
- token: terminalStream.token,
2198
- expiresAt: terminalStream.expiresAt,
2199
- },
2200
- }
2201
- : {}),
2202
2222
  });
2203
2223
 
2204
2224
  const repositoryRoots = [
@@ -2316,20 +2336,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2316
2336
  message: "invalid viewer attach request",
2317
2337
  });
2318
2338
  }
2319
- // Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
2320
- // requires the calling principal's acknowledgment (recorded per group+subject;
2321
- // a shared box additionally needs the shared-exposure consent). A TERMINAL-ONLY
2322
- // warm attach (`desktop:false`, the default) carries NO consent gate — a shell
2323
- // is interactive by nature and the gate is the scoped tunnel URL + stream token
2324
- // — so it warms the box and mints the pty-ws terminal cell without a 409. Gating
2325
- // the terminal attach behind the desktop ack (the bug this fixes) dead-ended the
2326
- // interactive terminal: the box never warmed → the Terminal cell stayed on the
2327
- // read-only sse-events firehose forever ("read only"), and with the desktop tier
2328
- // off by default there was no consent flow to ever clear the gate.
2339
+ // Resolve exact requested planes before authorization. Empty and
2340
+ // `desktop:false` v1 bodies remain terminal-only during rolling upgrades;
2341
+ // either new plane flag selects exact semantics. This closes desktop→terminal
2342
+ // privilege bleed and lets file edits avoid unrelated terminal bearers.
2329
2343
  const wantDesktop = parsed.data.desktop ?? false;
2330
- requirePermission(grant, wantDesktop ? "stream:view" : "terminal:attach");
2331
- const { shared } = await resolveSharedExposure(workspaceId, session);
2344
+ const wantFiles = parsed.data.files ?? false;
2345
+ const hasExactPlaneSet = parsed.data.terminal !== undefined || parsed.data.files !== undefined;
2346
+ // v1 clients sent only `desktop:false` for both terminal and file warming;
2347
+ // retain its terminal-only grant during rolling upgrades. Presence of either
2348
+ // v2 flag switches to exact-plane semantics.
2349
+ const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
2350
+ if (!wantDesktop && !wantTerminal && !wantFiles) {
2351
+ throw new HTTPException(400, { message: "viewer attach requires a live plane" });
2352
+ }
2353
+ if (wantDesktop) requirePermission(grant, "stream:view");
2354
+ if (wantTerminal) requirePermission(grant, "terminal:attach");
2355
+ if (wantFiles) requirePermission(grant, "files:write");
2356
+
2357
+ // Consent gate (P3.2 / addendum E.1): only the explicitly requested,
2358
+ // un-redacted desktop plane needs acknowledgment. Terminal and files retain
2359
+ // their independent permission boundaries.
2332
2360
  if (wantDesktop) {
2361
+ const { shared } = await resolveSharedExposure(workspaceId, session);
2333
2362
  const ack = await getStreamAcknowledgment(db, {
2334
2363
  workspaceId,
2335
2364
  sandboxGroupId: session.sandboxGroupId,
@@ -2372,7 +2401,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2372
2401
  dataPlaneUrl: null,
2373
2402
  };
2374
2403
  if (
2375
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2404
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2405
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2376
2406
  !streamTokenDegraded(settings)
2377
2407
  ) {
2378
2408
  if (wantDesktop && settings.sandboxDesktopEnabled) {
@@ -2384,7 +2414,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2384
2414
  // No Modal lease for selfhosted-active; the mint routes to the relay.
2385
2415
  });
2386
2416
  }
2387
- if (settings.sandboxTerminalEnabled) {
2417
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2388
2418
  terminal = await mintTerminalStream(viewerServices, {
2389
2419
  accountId: grant.accountId,
2390
2420
  workspaceId,
@@ -2399,6 +2429,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2399
2429
  accountId: grant.accountId,
2400
2430
  workspaceId,
2401
2431
  session,
2432
+ waitSignal: c.req.raw.signal,
2402
2433
  ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
2403
2434
  });
2404
2435
 
@@ -2410,7 +2441,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2410
2441
  // box is warm here (attachViewer spun it up or attached), so the handshake's
2411
2442
  // never-spin-up rule does not apply.
2412
2443
  if (
2413
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2444
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2445
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2414
2446
  !streamTokenDegraded(settings)
2415
2447
  ) {
2416
2448
  const lease = await readGroupLease(
@@ -2418,9 +2450,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2418
2450
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
2419
2451
  );
2420
2452
  if (lease) {
2421
- // The pixel cell is minted only when the caller asked for the desktop plane
2422
- // (and consented above). A terminal-only attach skips it the box is warm,
2423
- // the terminal mint below still runs.
2453
+ // Mint only explicitly authorized plane credentials. The shared holder
2454
+ // supplies liveness; it is not itself authority for another plane.
2424
2455
  if (wantDesktop && settings.sandboxDesktopEnabled) {
2425
2456
  stream = await mintDesktopStream(viewerServices, {
2426
2457
  accountId: grant.accountId,
@@ -2430,10 +2461,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2430
2461
  lease,
2431
2462
  });
2432
2463
  }
2433
- // P5.t the same warm-box viewer attach also mints the REAL PTY terminal
2434
- // address (independent of the desktop toggle). A degraded mint leaves the
2435
- // terminal fields null → the client falls back to the sse-events firehose.
2436
- if (settings.sandboxTerminalEnabled) {
2464
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2437
2465
  terminal = await mintTerminalStream(viewerServices, {
2438
2466
  accountId: grant.accountId,
2439
2467
  workspaceId,
@@ -2445,42 +2473,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2445
2473
  }
2446
2474
  }
2447
2475
  }
2448
- return c.json(
2449
- {
2450
- ...result,
2451
- dataPlaneUrl: stream?.url ?? result.dataPlaneUrl,
2452
- streamToken: stream?.token ?? null,
2453
- streamExpiresAt: stream?.expiresAt ?? null,
2454
- resolution: stream?.resolution ?? null,
2455
- // Transport MUST match where the pixels were minted: a selfhosted-active box
2456
- // serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
2457
- // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
2458
- // noVNC renderer (and vice-versa on the swap-away case) "closed before it
2459
- // opened". Key off the SAME selfhostedActive the mint routed on.
2460
- transport: stream
2461
- ? selfhostedActive
2462
- ? ("relay-frames" as const)
2463
- : ("vnc-ws" as const)
2464
- : null,
2465
- client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
2466
- // The REAL PTY terminal address (pty-ws), null when degraded.
2467
- terminalUrl: terminal?.url ?? null,
2468
- terminalToken: terminal?.token ?? null,
2469
- terminalExpiresAt: terminal?.expiresAt ?? null,
2470
- terminalTransport: terminal ? ("pty-ws" as const) : null,
2471
- },
2472
- 201,
2473
- );
2476
+ const response = {
2477
+ ...result,
2478
+ dataPlaneUrl: stream?.url ?? null,
2479
+ streamToken: stream?.token ?? null,
2480
+ streamExpiresAt: stream?.expiresAt ?? null,
2481
+ resolution: stream?.resolution ?? null,
2482
+ // Transport MUST match where the pixels were minted: a selfhosted-active box
2483
+ // serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
2484
+ // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
2485
+ // noVNC renderer (and vice-versa on the swap-away case) "closed before it
2486
+ // opened". Key off the SAME selfhostedActive the mint routed on.
2487
+ transport: stream
2488
+ ? selfhostedActive
2489
+ ? ("relay-frames" as const)
2490
+ : ("vnc-ws" as const)
2491
+ : null,
2492
+ client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
2493
+ // The REAL PTY terminal address (pty-ws), null when degraded.
2494
+ terminalUrl: terminal?.url ?? null,
2495
+ terminalToken: terminal?.token ?? null,
2496
+ terminalExpiresAt: terminal?.expiresAt ?? null,
2497
+ terminalTransport: terminal ? ("pty-ws" as const) : null,
2498
+ } satisfies AttachViewerResponse;
2499
+ return c.json(response, 201);
2474
2500
  });
2475
2501
 
2476
2502
  // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
2477
- // A holder can represent the terminal-only plane, so lifecycle control uses
2478
- // terminal:attach. Desktop callers continue to pass via workspace:admin.
2503
+ // A holder may belong to any exact live plane. Lifecycle control accepts any
2504
+ // permission that could have minted it; the unguessable holder id and workspace
2505
+ // grant remain the ownership boundary.
2479
2506
  app.post(
2480
2507
  "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
2481
2508
  async (c) => {
2482
2509
  const workspaceId = c.req.param("workspaceId");
2483
- const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
2510
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2511
+ requireViewerLifecyclePermission(grant);
2484
2512
  assertOwnershipEnabled();
2485
2513
  const sessionId = c.req.param("sessionId");
2486
2514
  const session = await getSession(db, workspaceId, sessionId);
@@ -2510,7 +2538,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2510
2538
  // DELETE .../viewers/:viewerId — release the holder (idempotent).
2511
2539
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
2512
2540
  const workspaceId = c.req.param("workspaceId");
2513
- const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
2541
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2542
+ requireViewerLifecyclePermission(grant);
2514
2543
  assertOwnershipEnabled();
2515
2544
  const sessionId = c.req.param("sessionId");
2516
2545
  const session = await getSession(db, workspaceId, sessionId);
@@ -2577,11 +2606,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2577
2606
  // FS uses files:read for reads, files:write for mutations; Git is read-only
2578
2607
  // (rides files:read); Terminal exec + PTY ride terminal:attach.
2579
2608
 
2580
- type ChannelARouteCtx = {
2581
- accountId: string;
2582
- workspaceId: string;
2583
- session: Session;
2584
- subjectId: string;
2609
+ type ChannelARouteCtx = ChannelAContext & {
2610
+ waitSignal: AbortSignal;
2611
+ operation: ChannelAOperation;
2585
2612
  };
2586
2613
 
2587
2614
  // Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
@@ -2589,6 +2616,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2589
2616
  async function channelAPreamble(
2590
2617
  c: Context,
2591
2618
  permission: "files:read" | "files:write" | "terminal:attach",
2619
+ operation: ChannelAOperation,
2592
2620
  ): Promise<ChannelARouteCtx> {
2593
2621
  const workspaceId = c.req.param("workspaceId") ?? "";
2594
2622
  const grant = await requireAccessGrant(c, deps, workspaceId, permission);
@@ -2603,6 +2631,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2603
2631
  workspaceId,
2604
2632
  session,
2605
2633
  subjectId: grant.subjectId,
2634
+ waitSignal: c.req.raw.signal,
2635
+ operation,
2606
2636
  };
2607
2637
  }
2608
2638
 
@@ -2622,55 +2652,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2622
2652
 
2623
2653
  // ── FileSystem ──────────────────────────────────────────────────────────
2624
2654
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
2625
- const ctx = await channelAPreamble(c, "files:read");
2655
+ const ctx = await channelAPreamble(c, "files:read", "fs.list");
2626
2656
  const req = await parseChannelABody(c, FsListRequest);
2627
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsList(req));
2657
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
2628
2658
  return c.json(out);
2629
2659
  });
2630
2660
 
2631
2661
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
2632
- const ctx = await channelAPreamble(c, "files:read");
2662
+ const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
2633
2663
  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
- ),
2664
+ const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
2665
+ results: await runConcurrentChannelAReads(
2666
+ req.requests.map((request) => async () => await service.fsList(request)),
2639
2667
  ),
2640
2668
  }));
2641
2669
  return c.json(out);
2642
2670
  });
2643
2671
 
2644
2672
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
2645
- const ctx = await channelAPreamble(c, "files:read");
2673
+ const ctx = await channelAPreamble(c, "files:read", "fs.read");
2646
2674
  const req = await parseChannelABody(c, FsReadRequest);
2647
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsRead(req));
2675
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
2648
2676
  return c.json(out);
2649
2677
  });
2650
2678
 
2651
2679
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
2652
- const ctx = await channelAPreamble(c, "files:write");
2680
+ const ctx = await channelAPreamble(c, "files:write", "fs.write");
2653
2681
  const req = await parseChannelABody(c, FsWriteRequest);
2654
2682
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
2655
2683
  return c.json(out);
2656
2684
  });
2657
2685
 
2658
2686
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
2659
- const ctx = await channelAPreamble(c, "files:write");
2687
+ const ctx = await channelAPreamble(c, "files:write", "fs.delete");
2660
2688
  const req = await parseChannelABody(c, FsDeleteRequest);
2661
2689
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
2662
2690
  return c.json(out);
2663
2691
  });
2664
2692
 
2665
2693
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
2666
- const ctx = await channelAPreamble(c, "files:write");
2694
+ const ctx = await channelAPreamble(c, "files:write", "fs.move");
2667
2695
  const req = await parseChannelABody(c, FsMoveRequest);
2668
2696
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
2669
2697
  return c.json(out);
2670
2698
  });
2671
2699
 
2672
2700
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
2673
- const ctx = await channelAPreamble(c, "files:write");
2701
+ const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
2674
2702
  const req = await parseChannelABody(c, FsMkdirRequest);
2675
2703
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
2676
2704
  return c.json(out);
@@ -2678,50 +2706,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2678
2706
 
2679
2707
  // ── Git (read-only) ─────────────────────────────────────────────────────
2680
2708
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
2681
- const ctx = await channelAPreamble(c, "files:read");
2709
+ const ctx = await channelAPreamble(c, "files:read", "git.status");
2682
2710
  const req = await parseChannelABody(c, GitStatusRequest);
2683
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitStatus(req));
2711
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2712
+ service.gitStatus(req),
2713
+ );
2684
2714
  return c.json(out);
2685
2715
  });
2686
2716
 
2687
2717
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
2688
- const ctx = await channelAPreamble(c, "files:read");
2718
+ const ctx = await channelAPreamble(c, "files:read", "git.diff");
2689
2719
  const req = await parseChannelABody(c, GitDiffRequest);
2690
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitDiff(req));
2720
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2721
+ service.gitDiff(req),
2722
+ );
2691
2723
  return c.json(out);
2692
2724
  });
2693
2725
 
2694
2726
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
2695
- const ctx = await channelAPreamble(c, "files:read");
2727
+ const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
2696
2728
  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) => {
2729
+ const out = await withChannelARead(channelAServices, ctx, async ({ service }) => {
2730
+ type StatusResult = Awaited<ReturnType<typeof service.gitStatus>>;
2731
+ type DiffResult = Awaited<ReturnType<typeof service.gitDiff>>;
2732
+ type ReadResult =
2733
+ | { requestIndex: number; kind: "status"; value: StatusResult }
2734
+ | { requestIndex: number; kind: "diff"; value: DiffResult };
2735
+ const operations: Array<() => Promise<ReadResult>> = [];
2736
+ req.requests.forEach((request, requestIndex) => {
2737
+ operations.push(async () => ({
2738
+ requestIndex,
2739
+ kind: "status" as const,
2740
+ value: await service.gitStatus(request.status),
2741
+ }));
2742
+ if (request.diff) {
2700
2743
  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
- ]);
2744
+ operations.push(async () => ({
2745
+ requestIndex,
2746
+ kind: "diff" as const,
2747
+ value: await service.gitDiff(diffRequest),
2748
+ }));
2749
+ }
2750
+ });
2751
+
2752
+ const reads = await runConcurrentChannelAReads(operations);
2753
+ const statuses = new Map<number, StatusResult>();
2754
+ const diffs = new Map<number, DiffResult>();
2755
+ for (const read of reads) {
2756
+ if (read.kind === "status") statuses.set(read.requestIndex, read.value);
2757
+ else diffs.set(read.requestIndex, read.value);
2758
+ }
2759
+
2760
+ return {
2761
+ results: req.requests.map((request, requestIndex) => {
2762
+ const status = statuses.get(requestIndex);
2763
+ if (!status) {
2764
+ throw new Error(`Workspace Git batch omitted status result ${requestIndex}.`);
2765
+ }
2766
+ const diff = request.diff ? diffs.get(requestIndex) : undefined;
2767
+ if (request.diff && !diff) {
2768
+ throw new Error(`Workspace Git batch omitted diff result ${requestIndex}.`);
2769
+ }
2707
2770
  return { status, ...(diff ? { diff } : {}) };
2708
2771
  }),
2709
- ),
2710
- }));
2772
+ };
2773
+ });
2711
2774
  return c.json(out);
2712
2775
  });
2713
2776
 
2714
2777
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
2715
- const ctx = await channelAPreamble(c, "files:read");
2778
+ const ctx = await channelAPreamble(c, "files:read", "git.log");
2716
2779
  const req = await parseChannelABody(c, GitLogRequest);
2717
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitLog(req));
2780
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
2718
2781
  return c.json(out);
2719
2782
  });
2720
2783
 
2721
2784
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
2722
- const ctx = await channelAPreamble(c, "files:read");
2785
+ const ctx = await channelAPreamble(c, "files:read", "git.show");
2723
2786
  const req = await parseChannelABody(c, GitShowRequest);
2724
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitShow(req));
2787
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2788
+ service.gitShow(req),
2789
+ );
2725
2790
  return c.json(out);
2726
2791
  });
2727
2792
 
@@ -2784,7 +2849,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2784
2849
 
2785
2850
  // ── Terminal: synchronous exec ────────────────────────────────────────────
2786
2851
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
2787
- const ctx = await channelAPreamble(c, "terminal:attach");
2852
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
2788
2853
  const req = await parseChannelABody(c, TerminalExecRequest);
2789
2854
  const out = await withChannelA(channelAServices, ctx, ({ service }) =>
2790
2855
  service.terminalExec(req),
@@ -2794,7 +2859,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2794
2859
 
2795
2860
  // ── Terminal: interactive PTY control (output rides A1) ───────────────────
2796
2861
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
2797
- const ctx = await channelAPreamble(c, "terminal:attach");
2862
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
2798
2863
  const req = await parseChannelABody(c, PtyOpenRequest);
2799
2864
  if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
2800
2865
  throw new HTTPException(409, {
@@ -2903,7 +2968,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2903
2968
  });
2904
2969
 
2905
2970
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
2906
- const ctx = await channelAPreamble(c, "terminal:attach");
2971
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
2907
2972
  const req = await parseChannelABody(c, PtyWriteRequest);
2908
2973
  const pty = await getOpenPtySession(db, {
2909
2974
  workspaceId: ctx.workspaceId,
@@ -2967,7 +3032,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2967
3032
  });
2968
3033
 
2969
3034
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
2970
- const ctx = await channelAPreamble(c, "terminal:attach");
3035
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
2971
3036
  const req = await parseChannelABody(c, PtyResizeRequest);
2972
3037
  const pty = await getOpenPtySession(db, {
2973
3038
  workspaceId: ctx.workspaceId,
@@ -2999,7 +3064,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2999
3064
  });
3000
3065
 
3001
3066
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
3002
- const ctx = await channelAPreamble(c, "terminal:attach");
3067
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
3003
3068
  const req = await parseChannelABody(c, PtyCloseRequest);
3004
3069
  const pty = await getOpenPtySession(db, {
3005
3070
  workspaceId: ctx.workspaceId,
@@ -3311,6 +3376,99 @@ function sessionListQuery(
3311
3376
  };
3312
3377
  }
3313
3378
 
3379
+ export type AgentTopologyCursorEnvelope = {
3380
+ cursor: SessionDiscoveryCursor;
3381
+ parentSessionId: string | null;
3382
+ search: string | null;
3383
+ };
3384
+
3385
+ export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
3386
+ return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
3387
+ }
3388
+
3389
+ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3390
+ if (value.length > 2_048) {
3391
+ throw new HTTPException(400, { message: "agent topology cursor is invalid" });
3392
+ }
3393
+ try {
3394
+ const parsed = z
3395
+ .object({
3396
+ v: z.literal(1),
3397
+ parentSessionId: z.string().uuid().nullable(),
3398
+ search: z.string().max(200).nullable(),
3399
+ cursor: z.object({
3400
+ orderBy: z.enum(["createdAt", "updatedAt"]),
3401
+ sortRevision: z.string().max(64),
3402
+ sortAt: z.string().max(64),
3403
+ id: z.string().uuid(),
3404
+ snapshotAt: z.string().max(64),
3405
+ snapshotRevision: z.string().max(64),
3406
+ updatedAfter: z.string().max(64).nullable(),
3407
+ }),
3408
+ })
3409
+ .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
3410
+ if (
3411
+ parsed.cursor.orderBy !== "updatedAt" ||
3412
+ parsed.cursor.updatedAfter !== null ||
3413
+ !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
3414
+ !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
3415
+ BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
3416
+ BigInt(parsed.cursor.snapshotRevision) > 9_223_372_036_854_775_807n ||
3417
+ Number.isNaN(Date.parse(parsed.cursor.sortAt)) ||
3418
+ Number.isNaN(Date.parse(parsed.cursor.snapshotAt))
3419
+ ) {
3420
+ throw new Error("invalid topology cursor fields");
3421
+ }
3422
+ return parsed;
3423
+ } catch {
3424
+ throw new HTTPException(400, { message: "agent topology cursor is invalid" });
3425
+ }
3426
+ }
3427
+
3428
+ export function agentTopologyQuery(query: Record<string, string>): {
3429
+ limit: number;
3430
+ parentSessionId: string | null;
3431
+ search: string | undefined;
3432
+ cursor: SessionDiscoveryCursor | undefined;
3433
+ } {
3434
+ const rawLimit = query.limit;
3435
+ const limit = rawLimit === undefined ? 25 : Number(rawLimit);
3436
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
3437
+ throw new HTTPException(400, { message: "limit must be an integer between 1 and 100" });
3438
+ }
3439
+ const rawParent = query.parentSessionId;
3440
+ if (
3441
+ rawParent !== undefined &&
3442
+ rawParent !== "null" &&
3443
+ !z.string().uuid().safeParse(rawParent).success
3444
+ ) {
3445
+ throw new HTTPException(400, {
3446
+ message: 'parentSessionId must be a session id or the literal "null"',
3447
+ });
3448
+ }
3449
+ const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
3450
+ const search = query.search?.trim();
3451
+ if (search && search.length > 200) {
3452
+ throw new HTTPException(400, { message: "search must be at most 200 characters" });
3453
+ }
3454
+ if (search && rawParent !== undefined) {
3455
+ throw new HTTPException(400, { message: "search cannot be combined with parentSessionId" });
3456
+ }
3457
+ const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
3458
+ if (
3459
+ envelope &&
3460
+ (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
3461
+ ) {
3462
+ throw new HTTPException(400, { message: "agent topology cursor does not match its filters" });
3463
+ }
3464
+ return {
3465
+ limit,
3466
+ parentSessionId,
3467
+ search: search || undefined,
3468
+ cursor: envelope?.cursor,
3469
+ };
3470
+ }
3471
+
3314
3472
  function compactEvents(raw: string | undefined): boolean {
3315
3473
  return raw === "1" || raw === "true";
3316
3474
  }
@@ -3363,6 +3521,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
3363
3521
  throw error;
3364
3522
  }
3365
3523
 
3524
+ export function parseSessionEventAdmission(raw: unknown): ClientSessionEvent {
3525
+ const parsed = ClientSessionEvent.safeParse(raw);
3526
+ if (!parsed.success) {
3527
+ throw new HTTPException(422, { message: "invalid session event" });
3528
+ }
3529
+ return parsed.data;
3530
+ }
3531
+
3532
+ export function parseSteerSessionAdmission(raw: unknown): SteerSessionMessageRequest {
3533
+ const parsed = SteerSessionMessageRequest.safeParse(raw);
3534
+ if (!parsed.success) {
3535
+ throw new HTTPException(422, { message: "invalid steer request" });
3536
+ }
3537
+ return parsed.data;
3538
+ }
3539
+
3366
3540
  function zodErrorFields(error: ZodError): string {
3367
3541
  const paths = [
3368
3542
  ...new Set(
@@ -3398,10 +3572,11 @@ type EffectivePolicyContext = {
3398
3572
  async function loadEffectivePolicyContext(
3399
3573
  deps: ApiRouteDeps,
3400
3574
  workspaceId: string,
3575
+ subjectId: string,
3401
3576
  ): Promise<EffectivePolicyContext> {
3402
3577
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
3403
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
3404
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
3578
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
3579
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
3405
3580
  ]);
3406
3581
  return { workspaceServerIds, workspaceDefaultServerIds };
3407
3582
  }
@@ -3409,9 +3584,10 @@ async function loadEffectivePolicyContext(
3409
3584
  async function withEffectivePolicy(
3410
3585
  deps: ApiRouteDeps,
3411
3586
  workspaceId: string,
3587
+ subjectId: string,
3412
3588
  session: Session,
3413
3589
  ): Promise<Session> {
3414
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
3590
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
3415
3591
  return sessionWithEffectiveToolPolicy(
3416
3592
  session,
3417
3593
  policy.workspaceServerIds,