@opengeni/api-router 0.23.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AcknowledgeStreamRequest,
3
+ ApplySessionGoalRevisionRequest,
3
4
  ActivateCodexRealtimeConnectionRequest,
4
5
  AttachViewerRequest,
5
6
  BeginSessionRealtimeRequest,
@@ -47,6 +48,7 @@ import {
47
48
  SteerSessionQueueItemRequest,
48
49
  SteerSessionMessageRequest,
49
50
  TerminalExecRequest,
51
+ UpdateSessionChannelRequest,
50
52
  UpdateSessionPinRequest,
51
53
  UpdateSessionGoalRequest,
52
54
  UpdateSessionMcpApprovalPolicyRequest,
@@ -55,9 +57,13 @@ import {
55
57
  ViewerHeartbeatRequest,
56
58
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
57
59
  workspaceControlUtf8Bytes,
60
+ type AccessGrant,
61
+ type AttachViewerResponse,
58
62
  type SandboxBackend,
59
63
  type LineageNode,
60
64
  type Session,
65
+ type SessionGoalRevision,
66
+ type AgentTopologyPageResponse,
61
67
  type ErrorCode,
62
68
  type SessionAuthorizationOperation,
63
69
  type SessionQueueSnapshot,
@@ -72,6 +78,7 @@ import {
72
78
  clearSessionGoal,
73
79
  clearSessionContext,
74
80
  getOpenPtySession,
81
+ getEnrollment,
75
82
  getRetainedProcess,
76
83
  getSandbox,
77
84
  getSession,
@@ -80,12 +87,16 @@ import {
80
87
  getSessionGoal,
81
88
  getSessionHumanInputRequest,
82
89
  getSessionGoalWithContinuation,
90
+ getSessionGoalRevision,
83
91
  getSessionQueueSnapshot,
84
92
  getStreamAcknowledgment,
85
93
  insertPtySession,
86
94
  listSessionEventPage,
87
95
  listSessionHumanInputRequests,
96
+ listSessionGoalRevisions,
88
97
  listSessionIdsInGroup,
98
+ listSessionDiscoverySummaries,
99
+ listSessionDiscoveryAncestorPaths,
89
100
  listSessionsForSubject,
90
101
  getLatestStartedSessionTurn,
91
102
  listSessionTurns,
@@ -93,8 +104,10 @@ import {
93
104
  projectSessionForRelatedAccess,
94
105
  recordStreamAcknowledgment,
95
106
  requestSessionCompaction,
96
- setSessionCodexPin,
97
- withCodexCapacityMutation,
107
+ setSessionCodexPinInTransaction,
108
+ withSessionCodexCapacityMutation,
109
+ setSessionChannel,
110
+ ChannelNotFoundError,
98
111
  setSessionPin,
99
112
  SessionPinVersionConflictError,
100
113
  SessionPinAccessError,
@@ -105,6 +118,7 @@ import {
105
118
  decodeSessionListCursor,
106
119
  revokeViewer,
107
120
  setSessionGoalStatusWithEvent,
121
+ upsertSessionGoalWithEvent,
108
122
  updatePtySessionActivity,
109
123
  QueueCommandConflictError,
110
124
  beginSessionRealtimeInTransaction,
@@ -124,6 +138,7 @@ import {
124
138
  sessionLatestWorkspaceCapture,
125
139
  renewSessionRealtimeInTransaction,
126
140
  syncSessionRealtimeLedgerInTransaction,
141
+ withWorkspaceSessionActivityRls,
127
142
  withWorkspaceRls,
128
143
  workspaceCaptureAtRevision,
129
144
  type AppendEventInput,
@@ -131,6 +146,8 @@ import {
131
146
  type SandboxPtyProcessIdentity,
132
147
  type SandboxRetainedProcess,
133
148
  type Database,
149
+ type SessionDiscoveryCursor,
150
+ type SessionDiscoveryAncestor,
134
151
  } from "@opengeni/db";
135
152
  import {
136
153
  appendAndPublishEvents,
@@ -142,17 +159,32 @@ import {
142
159
  createGatewayRealtimeConnectionSecret,
143
160
  GatewayRealtimeBrokerError,
144
161
  } from "../gateway-realtime";
162
+ import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
145
163
  import { z, ZodError } from "zod";
146
- import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
147
- import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
164
+ import {
165
+ runConcurrentChannelAReads,
166
+ withChannelA,
167
+ withChannelARead,
168
+ type ChannelAContext,
169
+ type ChannelAHandle,
170
+ type ChannelAOperation,
171
+ } from "../sandbox/channel-a";
172
+ import {
173
+ NatsControlRpc,
174
+ negotiateCapabilities,
175
+ negotiateSelfhostedCapabilities,
176
+ SelfhostedSession,
177
+ } from "@opengeni/runtime/sandbox";
148
178
  import type { Context, Hono, MiddlewareHandler } from "hono";
149
179
  import { HTTPException } from "hono/http-exception";
150
180
  import type { ContentfulStatusCode } from "hono/utils/http-status";
151
181
  import {
182
+ hasPermission,
152
183
  requireAccessGrant,
153
184
  requirePermission,
154
185
  requireSessionAuthorization,
155
186
  requireSessionAuthorizationListScope,
187
+ withResolvedSessionAuthorization,
156
188
  SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
157
189
  SessionAuthorizationDeniedError,
158
190
  SessionAuthorizationUnavailableError,
@@ -195,6 +227,7 @@ import {
195
227
  sessionWithEffectiveToolPolicy,
196
228
  workspaceSessionToolPolicyDefaultServerIds,
197
229
  workspaceSessionToolPolicyServerIds,
230
+ relayConfigFromSettings,
198
231
  } from "@opengeni/core";
199
232
  import { assertSessionExists, boundedLimit } from "../http/common";
200
233
  import { sseSessionStream } from "../http/sse";
@@ -206,9 +239,27 @@ import {
206
239
 
207
240
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
208
241
 
242
+ const VIEWER_LIFECYCLE_PERMISSIONS = ["stream:view", "terminal:attach", "files:write"] as const;
243
+
244
+ function requireViewerLifecyclePermission(grant: AccessGrant): void {
245
+ if (
246
+ VIEWER_LIFECYCLE_PERMISSIONS.some((permission) => hasPermission(grant.permissions, permission))
247
+ ) {
248
+ return;
249
+ }
250
+ throw new HTTPException(403, {
251
+ message: `missing permission: ${VIEWER_LIFECYCLE_PERMISSIONS.join(" or ")}`,
252
+ });
253
+ }
254
+
209
255
  export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
210
256
  const { settings, db, bus, workflowClient, objectStorage } = deps;
211
- const channelAServices = { db, settings, bus, observability: deps.observability };
257
+ const channelAServices = {
258
+ db,
259
+ settings,
260
+ bus,
261
+ observability: deps.observability,
262
+ };
212
263
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
213
264
  const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
214
265
  leaseId: pty.leaseId,
@@ -355,10 +406,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
355
406
  if (!operation) {
356
407
  throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
357
408
  }
358
- if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
359
- await next();
360
- return;
361
- }
362
409
  const grant = await requireAccessGrant(c, deps, workspaceId);
363
410
  try {
364
411
  const authorization = await requireSessionAuthorization(deps, grant, {
@@ -367,6 +414,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
367
414
  surface: "http",
368
415
  });
369
416
  if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
417
+ if (authorization) {
418
+ await withResolvedSessionAuthorization(authorization, next);
419
+ return;
420
+ }
370
421
  } catch (error) {
371
422
  throw sessionAuthorizationHttpError(error);
372
423
  }
@@ -407,7 +458,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
407
458
  // Creation has committed by this point. Keep response projection outside
408
459
  // the create-rejection boundary so a post-commit policy read cannot be
409
460
  // misreported as though the session itself was rejected.
410
- return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
461
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
411
462
  });
412
463
 
413
464
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
@@ -511,7 +562,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
511
562
  // body for older clients while still making its older-pin omission visible
512
563
  // to raw HTTP consumers without changing that response shape.
513
564
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
514
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
565
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
515
566
  const decorate = (session: Session): Session =>
516
567
  sessionWithEffectiveToolPolicy(
517
568
  session,
@@ -533,6 +584,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
533
584
  return c.json([...page.pinned, ...page.sessions].map(decorate));
534
585
  });
535
586
 
587
+ app.get("/v1/workspaces/:workspaceId/agent-topology", async (c) => {
588
+ const workspaceId = c.req.param("workspaceId");
589
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
590
+ let authorizationScope;
591
+ try {
592
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
593
+ } catch (error) {
594
+ throw sessionAuthorizationHttpError(error);
595
+ }
596
+ const query = agentTopologyQuery(c.req.query());
597
+ const page = await listSessionDiscoverySummaries(db, workspaceId, {
598
+ limit: query.limit,
599
+ orderBy: "updatedAt",
600
+ subjectId: grant.subjectId,
601
+ ...(query.cursor ? { cursor: query.cursor } : {}),
602
+ ...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
603
+ ...(authorizationScope ? { authorizationScope } : {}),
604
+ });
605
+ const ancestorPaths = query.search
606
+ ? await listSessionDiscoveryAncestorPaths(
607
+ db,
608
+ workspaceId,
609
+ page.sessions.map((session) => session.id),
610
+ authorizationScope ?? undefined,
611
+ )
612
+ : new Map<string, SessionDiscoveryAncestor[]>();
613
+ const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
614
+ const blocker = session.effectiveControl.primaryBlocker;
615
+ return {
616
+ id: session.id,
617
+ title: session.title,
618
+ titleTruncated:
619
+ session.titleOriginalChars !== null &&
620
+ session.titleOriginalChars > Array.from(session.title ?? "").length,
621
+ parentSessionId: session.parentSessionId,
622
+ rootSessionId: session.rootSessionId,
623
+ nestedAgentDepth: session.nestedAgentDepth,
624
+ ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
625
+ id: ancestor.id,
626
+ title: ancestor.title,
627
+ titleTruncated:
628
+ ancestor.titleOriginalChars !== null &&
629
+ ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
630
+ })),
631
+ status: session.status,
632
+ pause: {
633
+ state: session.effectiveControl.state,
634
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
635
+ source: blocker
636
+ ? {
637
+ kind: blocker.kind,
638
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
639
+ displayName: blocker.displayName,
640
+ displayNameTruncated:
641
+ blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
642
+ }
643
+ : null,
644
+ },
645
+ children: session.treeStats,
646
+ createdAt: session.createdAt,
647
+ updatedAt: session.updatedAt,
648
+ };
649
+ });
650
+ return c.json({
651
+ sessions,
652
+ total: page.total,
653
+ hasMore: page.hasMore,
654
+ nextCursor: page.nextCursor
655
+ ? encodeAgentTopologyCursor({
656
+ cursor: page.nextCursor,
657
+ parentSessionId: query.parentSessionId,
658
+ search: query.search ?? null,
659
+ })
660
+ : null,
661
+ } satisfies AgentTopologyPageResponse);
662
+ });
663
+
536
664
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
537
665
  const workspaceId = c.req.param("workspaceId");
538
666
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -550,7 +678,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
550
678
  if (!session) {
551
679
  throw new HTTPException(404, { message: "session not found" });
552
680
  }
553
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
681
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
554
682
  });
555
683
 
556
684
  const publishRealtimeMutation = async (
@@ -586,19 +714,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
586
714
  }
587
715
  const parsed = BeginSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
588
716
  if (!parsed.success) {
589
- throw new HTTPException(400, { message: "invalid session realtime request" });
717
+ throw new HTTPException(400, {
718
+ message: "invalid session realtime request",
719
+ });
590
720
  }
591
721
  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
- ),
722
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
723
+ beginSessionRealtimeInTransaction(scopedDb, {
724
+ accountId: grant.accountId,
725
+ workspaceId,
726
+ sessionId,
727
+ ownerSubjectId: grant.subjectId,
728
+ ...parsed.data,
729
+ }),
602
730
  );
603
731
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
604
732
  c.header("cache-control", "private, no-store");
@@ -619,23 +747,25 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
619
747
  !z.string().uuid().safeParse(sessionId).success ||
620
748
  !z.string().uuid().safeParse(realtimeId).success
621
749
  ) {
622
- throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
750
+ throw new HTTPException(400, {
751
+ message: "invalid realtime lifecycle id",
752
+ });
623
753
  }
624
754
  const parsed = RenewSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
625
755
  if (!parsed.success) {
626
- throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
756
+ throw new HTTPException(400, {
757
+ message: "invalid realtime heartbeat request",
758
+ });
627
759
  }
628
760
  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
- ),
761
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
762
+ renewSessionRealtimeInTransaction(scopedDb, {
763
+ workspaceId,
764
+ sessionId,
765
+ realtimeId,
766
+ ownerSubjectId: grant.subjectId,
767
+ ...parsed.data,
768
+ }),
639
769
  );
640
770
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
641
771
  c.header("cache-control", "private, no-store");
@@ -655,23 +785,25 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
655
785
  !z.string().uuid().safeParse(sessionId).success ||
656
786
  !z.string().uuid().safeParse(realtimeId).success
657
787
  ) {
658
- throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
788
+ throw new HTTPException(400, {
789
+ message: "invalid realtime lifecycle id",
790
+ });
659
791
  }
660
792
  const parsed = EndSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
661
793
  if (!parsed.success) {
662
- throw new HTTPException(400, { message: "invalid realtime end request" });
794
+ throw new HTTPException(400, {
795
+ message: "invalid realtime end request",
796
+ });
663
797
  }
664
798
  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
- ),
799
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
800
+ endSessionRealtimeInTransaction(scopedDb, {
801
+ workspaceId,
802
+ sessionId,
803
+ realtimeId,
804
+ ownerSubjectId: grant.subjectId,
805
+ ...parsed.data,
806
+ }),
675
807
  );
676
808
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
677
809
  c.header("cache-control", "private, no-store");
@@ -774,7 +906,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
774
906
  deps.codexFetch,
775
907
  );
776
908
  try {
777
- const answer = await broker({ request: providerRequest, signal: c.req.raw.signal });
909
+ const answer = await broker({
910
+ request: providerRequest,
911
+ signal: c.req.raw.signal,
912
+ });
778
913
  const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
779
914
  scopedDb.transaction(async (tx) =>
780
915
  completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
@@ -867,7 +1002,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
867
1002
  }
868
1003
  const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
869
1004
  if (!parsed.success) {
870
- throw new HTTPException(422, { message: "invalid Gateway realtime request" });
1005
+ throw new HTTPException(422, {
1006
+ message: "invalid Gateway realtime request",
1007
+ });
871
1008
  }
872
1009
  c.header("cache-control", "private, no-store");
873
1010
  const {
@@ -972,6 +1109,121 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
972
1109
  }
973
1110
  });
974
1111
 
1112
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/supergrok", async (c) => {
1113
+ const workspaceId = c.req.param("workspaceId");
1114
+ const sessionId = c.req.param("sessionId");
1115
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1116
+ if (!z.string().uuid().safeParse(sessionId).success) {
1117
+ throw new HTTPException(404, { message: "session not found" });
1118
+ }
1119
+ const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
1120
+ if (!parsed.success) {
1121
+ throw new HTTPException(422, { message: "invalid SuperGrok realtime request" });
1122
+ }
1123
+ c.header("cache-control", "private, no-store");
1124
+ const {
1125
+ realtimeId,
1126
+ operationId,
1127
+ browserInstanceId,
1128
+ ownerKey,
1129
+ expectedVersion,
1130
+ expectedConnectionEpoch,
1131
+ rotate,
1132
+ } = parsed.data;
1133
+ let claim: Awaited<ReturnType<typeof claimSessionRealtimeConnectionInTransaction>> | null =
1134
+ null;
1135
+ let connectionCompleted = false;
1136
+ try {
1137
+ claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1138
+ scopedDb.transaction(async (tx) =>
1139
+ claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1140
+ workspaceId,
1141
+ sessionId,
1142
+ realtimeId,
1143
+ operationId,
1144
+ ownerSubjectId: grant.subjectId,
1145
+ browserInstanceId,
1146
+ ownerKey,
1147
+ expectedVersion,
1148
+ expectedConnectionEpoch,
1149
+ rotate,
1150
+ promotionMode: "staged",
1151
+ }),
1152
+ ),
1153
+ );
1154
+ if (claim.replay) {
1155
+ throw new SessionRealtimeConflictError(
1156
+ "REALTIME_CONNECTION_STATE_CHANGED",
1157
+ "SuperGrok realtime tokens are single-use; reconnect with a new operation",
1158
+ );
1159
+ }
1160
+ const secret = await createXaiRealtimeConnectionSecret({
1161
+ db,
1162
+ settings,
1163
+ accountId: grant.accountId,
1164
+ workspaceId,
1165
+ subjectId: grant.subjectId,
1166
+ sessionId,
1167
+ model: claim.mode.model,
1168
+ fetchImpl: deps.xaiFetch ?? fetch,
1169
+ });
1170
+ const claimed = claim;
1171
+ const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1172
+ scopedDb.transaction(async (tx) =>
1173
+ completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1174
+ workspaceId,
1175
+ sessionId,
1176
+ realtimeId,
1177
+ connectionId: claimed.connection.id,
1178
+ operationId,
1179
+ connectionEpoch: claimed.connection.connectionEpoch,
1180
+ sdpAnswer: "supergrok-client-secret-minted",
1181
+ }),
1182
+ ),
1183
+ );
1184
+ connectionCompleted = true;
1185
+ return c.json({
1186
+ ...secret,
1187
+ connectionId: completed.connection.id,
1188
+ connectionEpoch: completed.connection.connectionEpoch,
1189
+ startupFenceSequence: completed.connection.startupFenceSequence,
1190
+ modeVersion: claimed.modeVersion,
1191
+ replay: false as const,
1192
+ });
1193
+ } catch (error) {
1194
+ if (claim !== null && !claim.replay && !connectionCompleted) {
1195
+ const claimed = claim;
1196
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1197
+ scopedDb.transaction(async (tx) =>
1198
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1199
+ workspaceId,
1200
+ sessionId,
1201
+ realtimeId,
1202
+ connectionId: claimed.connection.id,
1203
+ operationId,
1204
+ connectionEpoch: claimed.connection.connectionEpoch,
1205
+ failureCode: error instanceof XaiRealtimeBrokerError ? error.code : "supergrok_error",
1206
+ }),
1207
+ ),
1208
+ ).catch(() => undefined);
1209
+ }
1210
+ if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
1211
+ if (!(error instanceof XaiRealtimeBrokerError)) throw error;
1212
+ const status = error.code === "credential_unavailable" ? 409 : 502;
1213
+ return c.json(
1214
+ {
1215
+ error: {
1216
+ status,
1217
+ code: `SUPERGROK_REALTIME_${error.code.toUpperCase()}`,
1218
+ message: error.message,
1219
+ retryable: error.code === "provider_error",
1220
+ },
1221
+ },
1222
+ status,
1223
+ );
1224
+ }
1225
+ });
1226
+
975
1227
  app.post(
976
1228
  "/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/connections/:connectionId/activate",
977
1229
  async (c) => {
@@ -985,13 +1237,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
985
1237
  !z.string().uuid().safeParse(realtimeId).success ||
986
1238
  !z.string().uuid().safeParse(connectionId).success
987
1239
  ) {
988
- throw new HTTPException(400, { message: "invalid realtime connection id" });
1240
+ throw new HTTPException(400, {
1241
+ message: "invalid realtime connection id",
1242
+ });
989
1243
  }
990
1244
  const parsed = ActivateCodexRealtimeConnectionRequest.safeParse(
991
1245
  await c.req.json().catch(() => null),
992
1246
  );
993
1247
  if (!parsed.success) {
994
- throw new HTTPException(422, { message: "invalid realtime connection activation" });
1248
+ throw new HTTPException(422, {
1249
+ message: "invalid realtime connection activation",
1250
+ });
995
1251
  }
996
1252
  try {
997
1253
  const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
@@ -1031,19 +1287,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1031
1287
  await c.req.json().catch(() => null),
1032
1288
  );
1033
1289
  if (!parsed.success) {
1034
- throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
1290
+ throw new HTTPException(422, {
1291
+ message: "invalid realtime ledger sync request",
1292
+ });
1035
1293
  }
1036
1294
  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
- ),
1295
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1296
+ syncSessionRealtimeLedgerInTransaction(scopedDb, {
1297
+ workspaceId,
1298
+ sessionId,
1299
+ realtimeId,
1300
+ ownerSubjectId: grant.subjectId,
1301
+ ...parsed.data,
1302
+ }),
1047
1303
  );
1048
1304
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1049
1305
  c.header("cache-control", "private, no-store");
@@ -1082,6 +1338,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1082
1338
  await withEffectivePolicy(
1083
1339
  deps,
1084
1340
  workspaceId,
1341
+ grant.subjectId,
1085
1342
  projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
1086
1343
  ),
1087
1344
  );
@@ -1106,7 +1363,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1106
1363
  const workspaceId = c.req.param("workspaceId");
1107
1364
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1108
1365
  const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
1109
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
1366
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
1110
1367
  return c.json({
1111
1368
  ...lineage,
1112
1369
  ancestors: lineage.ancestors.map((session) =>
@@ -1148,11 +1405,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1148
1405
  }
1149
1406
  }
1150
1407
  const pinned = target === "auto" ? null : target;
1151
- const mutation = await withCodexCapacityMutation(
1408
+ const mutation = await withSessionCodexCapacityMutation(
1152
1409
  db,
1153
1410
  { workspaceId, reason: "codex_manual_session_pin_changed" },
1154
1411
  async (tx) => {
1155
- const changed = await setSessionCodexPin(tx, workspaceId, sessionId, pinned);
1412
+ const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
1156
1413
  return { result: changed, changed };
1157
1414
  },
1158
1415
  );
@@ -1185,6 +1442,43 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1185
1442
  return c.json({ pinned: target === "auto" ? "auto" : target });
1186
1443
  });
1187
1444
 
1445
+ // Re-file the session into a workspace channel (rail organization only;
1446
+ // null = back to the unfiled inbox). Shared, workspace-visible state, so it
1447
+ // requires sessions:control like rename. Returns the refreshed session.
1448
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/channel", async (c) => {
1449
+ const workspaceId = c.req.param("workspaceId");
1450
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1451
+ const sessionId = c.req.param("sessionId");
1452
+ await assertSessionExists(db, workspaceId, sessionId);
1453
+ const payload = UpdateSessionChannelRequest.parse(await c.req.json());
1454
+ try {
1455
+ const updated = await setSessionChannel(db, {
1456
+ workspaceId,
1457
+ sessionId,
1458
+ channelId: payload.channelId,
1459
+ });
1460
+ if (!updated) {
1461
+ throw new HTTPException(404, { message: "session not found" });
1462
+ }
1463
+ } catch (error) {
1464
+ if (error instanceof ChannelNotFoundError) {
1465
+ throw new HTTPException(422, { message: error.message });
1466
+ }
1467
+ throw error;
1468
+ }
1469
+ const session = await getSessionForSubject(
1470
+ db,
1471
+ workspaceId,
1472
+ sessionId,
1473
+ grant.subjectId,
1474
+ relatedSessionAccessFor(c),
1475
+ );
1476
+ if (!session) {
1477
+ throw new HTTPException(404, { message: "session not found" });
1478
+ }
1479
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1480
+ });
1481
+
1188
1482
  // Manual rename. A user-set title is permanent: the db write is
1189
1483
  // unconditional (source='user'), so it always pins the session over later
1190
1484
  // agent writes. Returns the refreshed session, mirroring GET detail.
@@ -1208,7 +1502,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1208
1502
  if (!session) {
1209
1503
  throw new HTTPException(404, { message: "session not found" });
1210
1504
  }
1211
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1505
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1212
1506
  });
1213
1507
 
1214
1508
  app.patch(
@@ -1246,7 +1540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1246
1540
  const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
1247
1541
  try {
1248
1542
  const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
1249
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1543
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1250
1544
  } catch (error) {
1251
1545
  if (error instanceof SessionToolPolicyVersionConflictError) {
1252
1546
  return c.json(
@@ -1274,6 +1568,77 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1274
1568
  return c.json(goal);
1275
1569
  });
1276
1570
 
1571
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions", async (c) => {
1572
+ const workspaceId = c.req.param("workspaceId");
1573
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1574
+ const sessionId = c.req.param("sessionId");
1575
+ await assertSessionExists(db, workspaceId, sessionId);
1576
+ return c.json(await listSessionGoalRevisions(db, workspaceId, sessionId));
1577
+ });
1578
+
1579
+ app.post(
1580
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/apply",
1581
+ async (c) => {
1582
+ const workspaceId = c.req.param("workspaceId");
1583
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1584
+ const sessionId = c.req.param("sessionId");
1585
+ await assertSessionExists(db, workspaceId, sessionId);
1586
+ const payload = ApplySessionGoalRevisionRequest.parse(await c.req.json());
1587
+ const revision = await getSessionGoalRevision(
1588
+ db,
1589
+ workspaceId,
1590
+ sessionId,
1591
+ c.req.param("revisionId"),
1592
+ );
1593
+ if (!revision || revision.disposition !== "proposed") {
1594
+ throw new HTTPException(404, {
1595
+ message: "goal rewrite proposal not found",
1596
+ });
1597
+ }
1598
+ if (!goalProposalMatchesExpectedRevision(revision, payload.expectedObjectiveRevision)) {
1599
+ throw new HTTPException(409, {
1600
+ message: `goal proposal was based on objective revision ${revision.baseObjectiveRevision}; requested fence ${payload.expectedObjectiveRevision} cannot apply it`,
1601
+ });
1602
+ }
1603
+ try {
1604
+ const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
1605
+ accountId: grant.accountId,
1606
+ workspaceId,
1607
+ sessionId,
1608
+ text: revision.text,
1609
+ successCriteria: revision.successCriteria,
1610
+ mutationPolicy: revision.mutationPolicy,
1611
+ expectedObjectiveRevision: payload.expectedObjectiveRevision,
1612
+ expectedGoalId: revision.goalId,
1613
+ changeKind: revision.changeKind,
1614
+ changeRationale: payload.rationale ?? `Applied goal proposal ${revision.id}`,
1615
+ sourceProposalId: revision.id,
1616
+ createdBy: "api",
1617
+ actor: "api",
1618
+ });
1619
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, events);
1620
+ if (workflowWakeRevision !== null) {
1621
+ await workflowClient.wakeSessionWorkflow({
1622
+ accountId: grant.accountId,
1623
+ workspaceId,
1624
+ sessionId,
1625
+ workflowId: workflowIdForSession(sessionId),
1626
+ wakeRevision: workflowWakeRevision,
1627
+ });
1628
+ }
1629
+ return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
1630
+ } catch (error) {
1631
+ if (error instanceof SessionControlConflictError) {
1632
+ throw new HTTPException(409, {
1633
+ message: error.message,
1634
+ cause: error,
1635
+ });
1636
+ }
1637
+ throw error;
1638
+ }
1639
+ },
1640
+ );
1641
+
1277
1642
  app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
1278
1643
  const workspaceId = c.req.param("workspaceId");
1279
1644
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -1284,6 +1649,48 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1284
1649
  if (!existing) {
1285
1650
  throw new HTTPException(404, { message: "session goal not found" });
1286
1651
  }
1652
+ if (!("status" in payload)) {
1653
+ try {
1654
+ const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
1655
+ accountId: grant.accountId,
1656
+ workspaceId,
1657
+ sessionId,
1658
+ text: payload.text,
1659
+ successCriteria:
1660
+ payload.successCriteria !== undefined
1661
+ ? payload.successCriteria
1662
+ : existing.successCriteria,
1663
+ maxAutoContinuations: existing.maxAutoContinuations,
1664
+ mutationPolicy: payload.mutationPolicy ?? existing.mutationPolicy,
1665
+ expectedObjectiveRevision: payload.expectedObjectiveRevision,
1666
+ changeKind: "replacement",
1667
+ changeRationale: payload.rationale,
1668
+ createdBy: "api",
1669
+ actor: "api",
1670
+ });
1671
+ if (events.length > 0) {
1672
+ await bus.publish(workspaceId, sessionId, events);
1673
+ }
1674
+ if (workflowWakeRevision !== null) {
1675
+ await workflowClient.wakeSessionWorkflow({
1676
+ accountId: grant.accountId,
1677
+ workspaceId,
1678
+ sessionId,
1679
+ workflowId: workflowIdForSession(sessionId),
1680
+ wakeRevision: workflowWakeRevision,
1681
+ });
1682
+ }
1683
+ return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
1684
+ } catch (error) {
1685
+ if (error instanceof SessionControlConflictError) {
1686
+ throw new HTTPException(409, {
1687
+ message: error.message,
1688
+ cause: error,
1689
+ });
1690
+ }
1691
+ throw error;
1692
+ }
1693
+ }
1287
1694
  if (existing.status === "completed") {
1288
1695
  throw new HTTPException(409, {
1289
1696
  message: "session goal is completed; set a new goal instead",
@@ -1563,6 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1563
1970
  const projected = compact ? coalesceSessionEventDeltas(events) : events;
1564
1971
  const page = boundSessionEventHttpPage(projected, {
1565
1972
  direction,
1973
+ eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
1566
1974
  });
1567
1975
  const hasMore = dbPage.hasMore || page.truncated;
1568
1976
  c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
@@ -1845,10 +2253,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1845
2253
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1846
2254
  const sessionId = c.req.param("sessionId");
1847
2255
  await assertSessionExists(db, workspaceId, sessionId);
1848
- const raw = await c.req.json();
1849
- const payload = SteerSessionMessageRequest.parse(raw);
2256
+ const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
1850
2257
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1851
2258
  text: payload.text,
2259
+ annotations: payload.annotations,
1852
2260
  turnInstructions: payload.turnInstructions ?? null,
1853
2261
  resources: payload.resources,
1854
2262
  model: payload.model ?? null,
@@ -1870,8 +2278,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1870
2278
  const workspaceId = c.req.param("workspaceId");
1871
2279
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1872
2280
  const sessionId = c.req.param("sessionId");
1873
- const rawEvent = await c.req.json();
1874
- const event = ClientSessionEvent.parse(rawEvent);
2281
+ const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
1875
2282
  const refinedOperation =
1876
2283
  event.type === "user.approvalDecision"
1877
2284
  ? "session.approval.write"
@@ -1892,6 +2299,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1892
2299
  if (event.type === "user.message") {
1893
2300
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1894
2301
  text: event.payload.text,
2302
+ annotations: event.payload.annotations,
1895
2303
  turnInstructions: event.payload.turnInstructions ?? null,
1896
2304
  resources: event.payload.resources ?? [],
1897
2305
  model: event.payload.model ?? null,
@@ -2056,8 +2464,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2056
2464
  // GET .../stream-capabilities — the capability-negotiation read. Returns the
2057
2465
  // SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
2058
2466
  // 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.
2467
+ // API-direct. It is a pure descriptor read: every URL/token stays null until an
2468
+ // exact, permission-checked POST /viewers grant mints it just in time.
2061
2469
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
2062
2470
  const workspaceId = c.req.param("workspaceId");
2063
2471
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2067,6 +2475,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2067
2475
  if (!session) {
2068
2476
  throw new HTTPException(404, { message: "session not found" });
2069
2477
  }
2478
+ // Capability truth follows the ACTIVE placement, not the session's managed
2479
+ // home lease. A connected machine has no Modal group lease; treating that
2480
+ // absence as `cold` makes the client suppress the very viewer attach that
2481
+ // would mint its relay terminal/desktop cells. Resolve the active target once
2482
+ // and use the selfhosted liveness probe when it is a connected machine.
2483
+ const activeSandbox = session.activeSandboxId
2484
+ ? await getSandbox(db, workspaceId, session.activeSandboxId)
2485
+ : null;
2486
+ const selfhostedActive = activeSandbox?.kind === "selfhosted";
2070
2487
  const lease = await readGroupLease(
2071
2488
  { db, settings },
2072
2489
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
@@ -2074,12 +2491,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2074
2491
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
2075
2492
  const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
2076
2493
  // 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).
2494
+ // Surface consent state so the UI can decide whether an explicit desktop
2495
+ // grant may be requested. This descriptor read never mints a credential; the
2496
+ // POST /viewers grant below re-checks both consent bits and stream:view.
2083
2497
  const ack = await getStreamAcknowledgment(db, {
2084
2498
  workspaceId,
2085
2499
  sandboxGroupId: session.sandboxGroupId,
@@ -2089,117 +2503,68 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2089
2503
  ? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
2090
2504
  : false;
2091
2505
 
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
-
2151
- const capabilities = negotiateCapabilities({
2506
+ // This GET is deliberately descriptor-only: no provider resume, display/ttyd
2507
+ // startup, port exposure, or short-lived bearer mint. Besides enforcing least
2508
+ // privilege, that keeps the 120-second stream credentials from aging before a
2509
+ // user opens their surface. POST /viewers is the sole credential grant.
2510
+ const commonNegotiation = {
2152
2511
  sessionId,
2153
- backend: session.sandboxBackend as SandboxBackend,
2154
- os: session.sandboxOs,
2155
- liveness: lease?.liveness ?? "cold",
2156
- leaseEpoch: lease?.leaseEpoch ?? 0,
2157
- workspaceGeneration: lease?.workspaceGeneration ?? null,
2158
- archiveGeneration: lease?.archiveGeneration ?? null,
2159
- archiveComplete: lease?.archiveComplete ?? false,
2160
2512
  desktopEnabled: settings.sandboxDesktopEnabled,
2161
2513
  // Human take-control: when the desktop is available + this policy is on
2162
2514
  // (default), the cell is mode "interactive" — the noVNC viewer drives :0
2163
2515
  // (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
2164
2516
  // take-control). Independent of the agent's computerUseReadOnly.
2165
2517
  desktopInteractive: settings.sandboxDesktopInteractive,
2166
- // P4.3 computer-use: the agent drives :0 (xdotool/scrot); availability
2518
+ // P4.3 computer-use: the agent drives the active display; availability
2167
2519
  // tracks the desktop tier + a desktop-capable backend.
2168
2520
  computerUseEnabled: settings.computerUseEnabled,
2169
2521
  computerUseReadOnly: settings.computerUseReadOnly,
2170
- // Graceful degrade (stream-token availability contract): if desktop is enabled but no stream-token
2171
- // secret is resolvable, the desktop cell reports transport:null rather
2172
- // than advertising a plane we can never authorize.
2522
+ // Graceful degrade when scoped stream credentials cannot be minted.
2173
2523
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
2174
2524
  desktopAcknowledged: acknowledged,
2175
2525
  shared,
2176
2526
  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.
2192
2527
  terminalEnabled: settings.sandboxTerminalEnabled,
2193
- ...(terminalStream
2194
- ? {
2195
- terminalStream: {
2196
- url: terminalStream.url,
2197
- token: terminalStream.token,
2198
- expiresAt: terminalStream.expiresAt,
2199
- },
2200
- }
2201
- : {}),
2202
- });
2528
+ } as const;
2529
+
2530
+ let capabilities;
2531
+ if (selfhostedActive && activeSandbox.enrollmentId) {
2532
+ const enrollment = await getEnrollment(db, workspaceId, activeSandbox.enrollmentId);
2533
+ let probeResponded = false;
2534
+ if (enrollment?.status === "active") {
2535
+ const machine = new SelfhostedSession({
2536
+ workspaceId,
2537
+ agentId: enrollment.id,
2538
+ controlRpc: new NatsControlRpc(async () => bus.getRequestConnection()),
2539
+ relay: relayConfigFromSettings(settings),
2540
+ epoch: session.activeEpoch,
2541
+ timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
2542
+ });
2543
+ try {
2544
+ probeResponded = await machine.ping();
2545
+ } catch {
2546
+ probeResponded = false;
2547
+ }
2548
+ }
2549
+ capabilities = await negotiateSelfhostedCapabilities({
2550
+ ...commonNegotiation,
2551
+ os: enrollment?.os ?? session.sandboxOs,
2552
+ leaseEpoch: session.activeEpoch,
2553
+ enrollment,
2554
+ probeResponded,
2555
+ });
2556
+ } else {
2557
+ capabilities = negotiateCapabilities({
2558
+ ...commonNegotiation,
2559
+ backend: session.sandboxBackend as SandboxBackend,
2560
+ os: session.sandboxOs,
2561
+ liveness: lease?.liveness ?? "cold",
2562
+ leaseEpoch: lease?.leaseEpoch ?? 0,
2563
+ workspaceGeneration: lease?.workspaceGeneration ?? null,
2564
+ archiveGeneration: lease?.archiveGeneration ?? null,
2565
+ archiveComplete: lease?.archiveComplete ?? false,
2566
+ });
2567
+ }
2203
2568
 
2204
2569
  const repositoryRoots = [
2205
2570
  ...new Set(
@@ -2238,11 +2603,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2238
2603
  },
2239
2604
  };
2240
2605
  if (capabilities.DesktopStream.transport !== null) {
2241
- const activeSandbox = session.activeSandboxId
2242
- ? await getSandbox(db, workspaceId, session.activeSandboxId)
2243
- : null;
2244
2606
  const wire = resolveActiveDesktopTransport(
2245
- activeSandbox?.kind === "selfhosted",
2607
+ selfhostedActive,
2246
2608
  settings.sandboxDesktopInteractive !== false,
2247
2609
  );
2248
2610
  responseCapabilities = {
@@ -2316,20 +2678,31 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2316
2678
  message: "invalid viewer attach request",
2317
2679
  });
2318
2680
  }
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.
2681
+ // Resolve exact requested planes before authorization. Empty and
2682
+ // `desktop:false` v1 bodies remain terminal-only during rolling upgrades;
2683
+ // either new plane flag selects exact semantics. This closes desktop→terminal
2684
+ // privilege bleed and lets file edits avoid unrelated terminal bearers.
2329
2685
  const wantDesktop = parsed.data.desktop ?? false;
2330
- requirePermission(grant, wantDesktop ? "stream:view" : "terminal:attach");
2331
- const { shared } = await resolveSharedExposure(workspaceId, session);
2686
+ const wantFiles = parsed.data.files ?? false;
2687
+ const hasExactPlaneSet = parsed.data.terminal !== undefined || parsed.data.files !== undefined;
2688
+ // v1 clients sent only `desktop:false` for both terminal and file warming;
2689
+ // retain its terminal-only grant during rolling upgrades. Presence of either
2690
+ // v2 flag switches to exact-plane semantics.
2691
+ const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
2692
+ if (!wantDesktop && !wantTerminal && !wantFiles) {
2693
+ throw new HTTPException(400, {
2694
+ message: "viewer attach requires a live plane",
2695
+ });
2696
+ }
2697
+ if (wantDesktop) requirePermission(grant, "stream:view");
2698
+ if (wantTerminal) requirePermission(grant, "terminal:attach");
2699
+ if (wantFiles) requirePermission(grant, "files:write");
2700
+
2701
+ // Consent gate (P3.2 / addendum E.1): only the explicitly requested,
2702
+ // un-redacted desktop plane needs acknowledgment. Terminal and files retain
2703
+ // their independent permission boundaries.
2332
2704
  if (wantDesktop) {
2705
+ const { shared } = await resolveSharedExposure(workspaceId, session);
2333
2706
  const ack = await getStreamAcknowledgment(db, {
2334
2707
  workspaceId,
2335
2708
  sandboxGroupId: session.sandboxGroupId,
@@ -2372,7 +2745,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2372
2745
  dataPlaneUrl: null,
2373
2746
  };
2374
2747
  if (
2375
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2748
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2749
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2376
2750
  !streamTokenDegraded(settings)
2377
2751
  ) {
2378
2752
  if (wantDesktop && settings.sandboxDesktopEnabled) {
@@ -2384,7 +2758,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2384
2758
  // No Modal lease for selfhosted-active; the mint routes to the relay.
2385
2759
  });
2386
2760
  }
2387
- if (settings.sandboxTerminalEnabled) {
2761
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2388
2762
  terminal = await mintTerminalStream(viewerServices, {
2389
2763
  accountId: grant.accountId,
2390
2764
  workspaceId,
@@ -2399,6 +2773,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2399
2773
  accountId: grant.accountId,
2400
2774
  workspaceId,
2401
2775
  session,
2776
+ waitSignal: c.req.raw.signal,
2402
2777
  ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
2403
2778
  });
2404
2779
 
@@ -2410,7 +2785,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2410
2785
  // box is warm here (attachViewer spun it up or attached), so the handshake's
2411
2786
  // never-spin-up rule does not apply.
2412
2787
  if (
2413
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2788
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2789
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2414
2790
  !streamTokenDegraded(settings)
2415
2791
  ) {
2416
2792
  const lease = await readGroupLease(
@@ -2418,9 +2794,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2418
2794
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
2419
2795
  );
2420
2796
  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.
2797
+ // Mint only explicitly authorized plane credentials. The shared holder
2798
+ // supplies liveness; it is not itself authority for another plane.
2424
2799
  if (wantDesktop && settings.sandboxDesktopEnabled) {
2425
2800
  stream = await mintDesktopStream(viewerServices, {
2426
2801
  accountId: grant.accountId,
@@ -2430,10 +2805,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2430
2805
  lease,
2431
2806
  });
2432
2807
  }
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) {
2808
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2437
2809
  terminal = await mintTerminalStream(viewerServices, {
2438
2810
  accountId: grant.accountId,
2439
2811
  workspaceId,
@@ -2445,42 +2817,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2445
2817
  }
2446
2818
  }
2447
2819
  }
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
- );
2820
+ const response = {
2821
+ ...result,
2822
+ dataPlaneUrl: stream?.url ?? null,
2823
+ streamToken: stream?.token ?? null,
2824
+ streamExpiresAt: stream?.expiresAt ?? null,
2825
+ resolution: stream?.resolution ?? null,
2826
+ // Transport MUST match where the pixels were minted: a selfhosted-active box
2827
+ // serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
2828
+ // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
2829
+ // noVNC renderer (and vice-versa on the swap-away case) "closed before it
2830
+ // opened". Key off the SAME selfhostedActive the mint routed on.
2831
+ transport: stream
2832
+ ? selfhostedActive
2833
+ ? ("relay-frames" as const)
2834
+ : ("vnc-ws" as const)
2835
+ : null,
2836
+ client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
2837
+ // The REAL PTY terminal address (pty-ws), null when degraded.
2838
+ terminalUrl: terminal?.url ?? null,
2839
+ terminalToken: terminal?.token ?? null,
2840
+ terminalExpiresAt: terminal?.expiresAt ?? null,
2841
+ terminalTransport: terminal?.transport ?? null,
2842
+ } satisfies AttachViewerResponse;
2843
+ return c.json(response, 201);
2474
2844
  });
2475
2845
 
2476
2846
  // 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.
2847
+ // A holder may belong to any exact live plane. Lifecycle control accepts any
2848
+ // permission that could have minted it; the unguessable holder id and workspace
2849
+ // grant remain the ownership boundary.
2479
2850
  app.post(
2480
2851
  "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
2481
2852
  async (c) => {
2482
2853
  const workspaceId = c.req.param("workspaceId");
2483
- const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
2854
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2855
+ requireViewerLifecyclePermission(grant);
2484
2856
  assertOwnershipEnabled();
2485
2857
  const sessionId = c.req.param("sessionId");
2486
2858
  const session = await getSession(db, workspaceId, sessionId);
@@ -2510,7 +2882,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2510
2882
  // DELETE .../viewers/:viewerId — release the holder (idempotent).
2511
2883
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
2512
2884
  const workspaceId = c.req.param("workspaceId");
2513
- const grant = await requireAccessGrant(c, deps, workspaceId, "terminal:attach");
2885
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2886
+ requireViewerLifecyclePermission(grant);
2514
2887
  assertOwnershipEnabled();
2515
2888
  const sessionId = c.req.param("sessionId");
2516
2889
  const session = await getSession(db, workspaceId, sessionId);
@@ -2577,11 +2950,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2577
2950
  // FS uses files:read for reads, files:write for mutations; Git is read-only
2578
2951
  // (rides files:read); Terminal exec + PTY ride terminal:attach.
2579
2952
 
2580
- type ChannelARouteCtx = {
2581
- accountId: string;
2582
- workspaceId: string;
2583
- session: Session;
2584
- subjectId: string;
2953
+ type ChannelARouteCtx = ChannelAContext & {
2954
+ waitSignal: AbortSignal;
2955
+ operation: ChannelAOperation;
2585
2956
  };
2586
2957
 
2587
2958
  // Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
@@ -2589,6 +2960,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2589
2960
  async function channelAPreamble(
2590
2961
  c: Context,
2591
2962
  permission: "files:read" | "files:write" | "terminal:attach",
2963
+ operation: ChannelAOperation,
2592
2964
  ): Promise<ChannelARouteCtx> {
2593
2965
  const workspaceId = c.req.param("workspaceId") ?? "";
2594
2966
  const grant = await requireAccessGrant(c, deps, workspaceId, permission);
@@ -2603,6 +2975,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2603
2975
  workspaceId,
2604
2976
  session,
2605
2977
  subjectId: grant.subjectId,
2978
+ waitSignal: c.req.raw.signal,
2979
+ operation,
2606
2980
  };
2607
2981
  }
2608
2982
 
@@ -2622,55 +2996,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2622
2996
 
2623
2997
  // ── FileSystem ──────────────────────────────────────────────────────────
2624
2998
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
2625
- const ctx = await channelAPreamble(c, "files:read");
2999
+ const ctx = await channelAPreamble(c, "files:read", "fs.list");
2626
3000
  const req = await parseChannelABody(c, FsListRequest);
2627
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsList(req));
3001
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
2628
3002
  return c.json(out);
2629
3003
  });
2630
3004
 
2631
3005
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
2632
- const ctx = await channelAPreamble(c, "files:read");
3006
+ const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
2633
3007
  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
- ),
3008
+ const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
3009
+ results: await runConcurrentChannelAReads(
3010
+ req.requests.map((request) => async () => await service.fsList(request)),
2639
3011
  ),
2640
3012
  }));
2641
3013
  return c.json(out);
2642
3014
  });
2643
3015
 
2644
3016
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
2645
- const ctx = await channelAPreamble(c, "files:read");
3017
+ const ctx = await channelAPreamble(c, "files:read", "fs.read");
2646
3018
  const req = await parseChannelABody(c, FsReadRequest);
2647
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsRead(req));
3019
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
2648
3020
  return c.json(out);
2649
3021
  });
2650
3022
 
2651
3023
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
2652
- const ctx = await channelAPreamble(c, "files:write");
3024
+ const ctx = await channelAPreamble(c, "files:write", "fs.write");
2653
3025
  const req = await parseChannelABody(c, FsWriteRequest);
2654
3026
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
2655
3027
  return c.json(out);
2656
3028
  });
2657
3029
 
2658
3030
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
2659
- const ctx = await channelAPreamble(c, "files:write");
3031
+ const ctx = await channelAPreamble(c, "files:write", "fs.delete");
2660
3032
  const req = await parseChannelABody(c, FsDeleteRequest);
2661
3033
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
2662
3034
  return c.json(out);
2663
3035
  });
2664
3036
 
2665
3037
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
2666
- const ctx = await channelAPreamble(c, "files:write");
3038
+ const ctx = await channelAPreamble(c, "files:write", "fs.move");
2667
3039
  const req = await parseChannelABody(c, FsMoveRequest);
2668
3040
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
2669
3041
  return c.json(out);
2670
3042
  });
2671
3043
 
2672
3044
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
2673
- const ctx = await channelAPreamble(c, "files:write");
3045
+ const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
2674
3046
  const req = await parseChannelABody(c, FsMkdirRequest);
2675
3047
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
2676
3048
  return c.json(out);
@@ -2678,50 +3050,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2678
3050
 
2679
3051
  // ── Git (read-only) ─────────────────────────────────────────────────────
2680
3052
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
2681
- const ctx = await channelAPreamble(c, "files:read");
3053
+ const ctx = await channelAPreamble(c, "files:read", "git.status");
2682
3054
  const req = await parseChannelABody(c, GitStatusRequest);
2683
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitStatus(req));
3055
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
3056
+ service.gitStatus(req),
3057
+ );
2684
3058
  return c.json(out);
2685
3059
  });
2686
3060
 
2687
3061
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
2688
- const ctx = await channelAPreamble(c, "files:read");
3062
+ const ctx = await channelAPreamble(c, "files:read", "git.diff");
2689
3063
  const req = await parseChannelABody(c, GitDiffRequest);
2690
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitDiff(req));
3064
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
3065
+ service.gitDiff(req),
3066
+ );
2691
3067
  return c.json(out);
2692
3068
  });
2693
3069
 
2694
3070
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
2695
- const ctx = await channelAPreamble(c, "files:read");
3071
+ const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
2696
3072
  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) => {
3073
+ const out = await withChannelARead(channelAServices, ctx, async ({ service }) => {
3074
+ type StatusResult = Awaited<ReturnType<typeof service.gitStatus>>;
3075
+ type DiffResult = Awaited<ReturnType<typeof service.gitDiff>>;
3076
+ type ReadResult =
3077
+ | { requestIndex: number; kind: "status"; value: StatusResult }
3078
+ | { requestIndex: number; kind: "diff"; value: DiffResult };
3079
+ const operations: Array<() => Promise<ReadResult>> = [];
3080
+ req.requests.forEach((request, requestIndex) => {
3081
+ operations.push(async () => ({
3082
+ requestIndex,
3083
+ kind: "status" as const,
3084
+ value: await service.gitStatus(request.status),
3085
+ }));
3086
+ if (request.diff) {
2700
3087
  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
- ]);
3088
+ operations.push(async () => ({
3089
+ requestIndex,
3090
+ kind: "diff" as const,
3091
+ value: await service.gitDiff(diffRequest),
3092
+ }));
3093
+ }
3094
+ });
3095
+
3096
+ const reads = await runConcurrentChannelAReads(operations);
3097
+ const statuses = new Map<number, StatusResult>();
3098
+ const diffs = new Map<number, DiffResult>();
3099
+ for (const read of reads) {
3100
+ if (read.kind === "status") statuses.set(read.requestIndex, read.value);
3101
+ else diffs.set(read.requestIndex, read.value);
3102
+ }
3103
+
3104
+ return {
3105
+ results: req.requests.map((request, requestIndex) => {
3106
+ const status = statuses.get(requestIndex);
3107
+ if (!status) {
3108
+ throw new Error(`Workspace Git batch omitted status result ${requestIndex}.`);
3109
+ }
3110
+ const diff = request.diff ? diffs.get(requestIndex) : undefined;
3111
+ if (request.diff && !diff) {
3112
+ throw new Error(`Workspace Git batch omitted diff result ${requestIndex}.`);
3113
+ }
2707
3114
  return { status, ...(diff ? { diff } : {}) };
2708
3115
  }),
2709
- ),
2710
- }));
3116
+ };
3117
+ });
2711
3118
  return c.json(out);
2712
3119
  });
2713
3120
 
2714
3121
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
2715
- const ctx = await channelAPreamble(c, "files:read");
3122
+ const ctx = await channelAPreamble(c, "files:read", "git.log");
2716
3123
  const req = await parseChannelABody(c, GitLogRequest);
2717
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitLog(req));
3124
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
2718
3125
  return c.json(out);
2719
3126
  });
2720
3127
 
2721
3128
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
2722
- const ctx = await channelAPreamble(c, "files:read");
3129
+ const ctx = await channelAPreamble(c, "files:read", "git.show");
2723
3130
  const req = await parseChannelABody(c, GitShowRequest);
2724
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitShow(req));
3131
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
3132
+ service.gitShow(req),
3133
+ );
2725
3134
  return c.json(out);
2726
3135
  });
2727
3136
 
@@ -2784,7 +3193,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2784
3193
 
2785
3194
  // ── Terminal: synchronous exec ────────────────────────────────────────────
2786
3195
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
2787
- const ctx = await channelAPreamble(c, "terminal:attach");
3196
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
2788
3197
  const req = await parseChannelABody(c, TerminalExecRequest);
2789
3198
  const out = await withChannelA(channelAServices, ctx, ({ service }) =>
2790
3199
  service.terminalExec(req),
@@ -2794,7 +3203,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2794
3203
 
2795
3204
  // ── Terminal: interactive PTY control (output rides A1) ───────────────────
2796
3205
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
2797
- const ctx = await channelAPreamble(c, "terminal:attach");
3206
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
2798
3207
  const req = await parseChannelABody(c, PtyOpenRequest);
2799
3208
  if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
2800
3209
  throw new HTTPException(409, {
@@ -2903,7 +3312,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2903
3312
  });
2904
3313
 
2905
3314
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
2906
- const ctx = await channelAPreamble(c, "terminal:attach");
3315
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
2907
3316
  const req = await parseChannelABody(c, PtyWriteRequest);
2908
3317
  const pty = await getOpenPtySession(db, {
2909
3318
  workspaceId: ctx.workspaceId,
@@ -2967,7 +3376,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2967
3376
  });
2968
3377
 
2969
3378
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
2970
- const ctx = await channelAPreamble(c, "terminal:attach");
3379
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
2971
3380
  const req = await parseChannelABody(c, PtyResizeRequest);
2972
3381
  const pty = await getOpenPtySession(db, {
2973
3382
  workspaceId: ctx.workspaceId,
@@ -2999,7 +3408,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2999
3408
  });
3000
3409
 
3001
3410
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
3002
- const ctx = await channelAPreamble(c, "terminal:attach");
3411
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
3003
3412
  const req = await parseChannelABody(c, PtyCloseRequest);
3004
3413
  const pty = await getOpenPtySession(db, {
3005
3414
  workspaceId: ctx.workspaceId,
@@ -3028,6 +3437,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3028
3437
  });
3029
3438
  }
3030
3439
 
3440
+ export function goalProposalMatchesExpectedRevision(
3441
+ revision: Pick<SessionGoalRevision, "baseObjectiveRevision">,
3442
+ expectedObjectiveRevision: number,
3443
+ ): boolean {
3444
+ return revision.baseObjectiveRevision === expectedObjectiveRevision;
3445
+ }
3446
+
3031
3447
  function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
3032
3448
  const limit = Number(raw ?? fallback);
3033
3449
  if (!Number.isFinite(limit)) {
@@ -3097,6 +3513,7 @@ export function sessionAuthorizationOperationForHttp(
3097
3513
  return null;
3098
3514
  }
3099
3515
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
3516
+ if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
3100
3517
  if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
3101
3518
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
3102
3519
  return "session.mcp.approval_policy.write";
@@ -3111,6 +3528,9 @@ export function sessionAuthorizationOperationForHttp(
3111
3528
  if (suffix === "/realtime/gateway" && verb === "POST") {
3112
3529
  return "session.realtime.start";
3113
3530
  }
3531
+ if (suffix === "/realtime/supergrok" && verb === "POST") {
3532
+ return "session.realtime.start";
3533
+ }
3114
3534
  if (suffix === "/realtime" && verb === "POST") {
3115
3535
  return "session.realtime.start";
3116
3536
  }
@@ -3133,6 +3553,10 @@ export function sessionAuthorizationOperationForHttp(
3133
3553
  ? "session.goal.write"
3134
3554
  : null;
3135
3555
  }
3556
+ if (suffix === "/goal/revisions" && verb === "GET") return "session.goal.read";
3557
+ if (/^\/goal\/revisions\/[^/]+\/apply$/.test(suffix) && verb === "POST") {
3558
+ return "session.goal.write";
3559
+ }
3136
3560
  if (suffix === "/context/clear" || suffix === "/context/compact") {
3137
3561
  return verb === "POST" ? "session.context.write" : null;
3138
3562
  }
@@ -3311,6 +3735,111 @@ function sessionListQuery(
3311
3735
  };
3312
3736
  }
3313
3737
 
3738
+ export type AgentTopologyCursorEnvelope = {
3739
+ cursor: SessionDiscoveryCursor;
3740
+ parentSessionId: string | null;
3741
+ search: string | null;
3742
+ };
3743
+
3744
+ export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
3745
+ return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
3746
+ }
3747
+
3748
+ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3749
+ if (value.length > 2_048) {
3750
+ throw new HTTPException(400, {
3751
+ message: "agent topology cursor is invalid",
3752
+ });
3753
+ }
3754
+ try {
3755
+ const parsed = z
3756
+ .object({
3757
+ v: z.literal(1),
3758
+ parentSessionId: z.string().uuid().nullable(),
3759
+ search: z.string().max(200).nullable(),
3760
+ cursor: z.object({
3761
+ orderBy: z.enum(["createdAt", "updatedAt"]),
3762
+ sortRevision: z.string().max(64),
3763
+ sortAt: z.string().max(64),
3764
+ id: z.string().uuid(),
3765
+ snapshotAt: z.string().max(64),
3766
+ snapshotRevision: z.string().max(64),
3767
+ updatedAfter: z.string().max(64).nullable(),
3768
+ }),
3769
+ })
3770
+ .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
3771
+ if (
3772
+ parsed.cursor.orderBy !== "updatedAt" ||
3773
+ parsed.cursor.updatedAfter !== null ||
3774
+ !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
3775
+ !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
3776
+ BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
3777
+ BigInt(parsed.cursor.snapshotRevision) > 9_223_372_036_854_775_807n ||
3778
+ Number.isNaN(Date.parse(parsed.cursor.sortAt)) ||
3779
+ Number.isNaN(Date.parse(parsed.cursor.snapshotAt))
3780
+ ) {
3781
+ throw new Error("invalid topology cursor fields");
3782
+ }
3783
+ return parsed;
3784
+ } catch {
3785
+ throw new HTTPException(400, {
3786
+ message: "agent topology cursor is invalid",
3787
+ });
3788
+ }
3789
+ }
3790
+
3791
+ export function agentTopologyQuery(query: Record<string, string>): {
3792
+ limit: number;
3793
+ parentSessionId: string | null;
3794
+ search: string | undefined;
3795
+ cursor: SessionDiscoveryCursor | undefined;
3796
+ } {
3797
+ const rawLimit = query.limit;
3798
+ const limit = rawLimit === undefined ? 25 : Number(rawLimit);
3799
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
3800
+ throw new HTTPException(400, {
3801
+ message: "limit must be an integer between 1 and 100",
3802
+ });
3803
+ }
3804
+ const rawParent = query.parentSessionId;
3805
+ if (
3806
+ rawParent !== undefined &&
3807
+ rawParent !== "null" &&
3808
+ !z.string().uuid().safeParse(rawParent).success
3809
+ ) {
3810
+ throw new HTTPException(400, {
3811
+ message: 'parentSessionId must be a session id or the literal "null"',
3812
+ });
3813
+ }
3814
+ const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
3815
+ const search = query.search?.trim();
3816
+ if (search && search.length > 200) {
3817
+ throw new HTTPException(400, {
3818
+ message: "search must be at most 200 characters",
3819
+ });
3820
+ }
3821
+ if (search && rawParent !== undefined) {
3822
+ throw new HTTPException(400, {
3823
+ message: "search cannot be combined with parentSessionId",
3824
+ });
3825
+ }
3826
+ const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
3827
+ if (
3828
+ envelope &&
3829
+ (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
3830
+ ) {
3831
+ throw new HTTPException(400, {
3832
+ message: "agent topology cursor does not match its filters",
3833
+ });
3834
+ }
3835
+ return {
3836
+ limit,
3837
+ parentSessionId,
3838
+ search: search || undefined,
3839
+ cursor: envelope?.cursor,
3840
+ };
3841
+ }
3842
+
3314
3843
  function compactEvents(raw: string | undefined): boolean {
3315
3844
  return raw === "1" || raw === "true";
3316
3845
  }
@@ -3336,6 +3865,11 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
3336
3865
 
3337
3866
  /** Stable, value-free JSON errors for only the create-session boundary. */
3338
3867
  export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
3868
+ if (error instanceof ChannelNotFoundError) {
3869
+ // Covers the create-vs-channel-delete race the pre-validation cannot: the
3870
+ // insert's FK rejection surfaces as the same 422 an unknown id gets.
3871
+ return c.json({ code: "SESSION_CREATE_REJECTED", message: error.message }, 422);
3872
+ }
3339
3873
  if (error instanceof SessionSpawnDeniedError) {
3340
3874
  return c.json(
3341
3875
  sessionSpawnDenialEnvelope(error),
@@ -3363,6 +3897,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
3363
3897
  throw error;
3364
3898
  }
3365
3899
 
3900
+ export function parseSessionEventAdmission(raw: unknown): ClientSessionEvent {
3901
+ const parsed = ClientSessionEvent.safeParse(raw);
3902
+ if (!parsed.success) {
3903
+ throw new HTTPException(422, { message: "invalid session event" });
3904
+ }
3905
+ return parsed.data;
3906
+ }
3907
+
3908
+ export function parseSteerSessionAdmission(raw: unknown): SteerSessionMessageRequest {
3909
+ const parsed = SteerSessionMessageRequest.safeParse(raw);
3910
+ if (!parsed.success) {
3911
+ throw new HTTPException(422, { message: "invalid steer request" });
3912
+ }
3913
+ return parsed.data;
3914
+ }
3915
+
3366
3916
  function zodErrorFields(error: ZodError): string {
3367
3917
  const paths = [
3368
3918
  ...new Set(
@@ -3398,10 +3948,11 @@ type EffectivePolicyContext = {
3398
3948
  async function loadEffectivePolicyContext(
3399
3949
  deps: ApiRouteDeps,
3400
3950
  workspaceId: string,
3951
+ subjectId: string,
3401
3952
  ): Promise<EffectivePolicyContext> {
3402
3953
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
3403
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
3404
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
3954
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
3955
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
3405
3956
  ]);
3406
3957
  return { workspaceServerIds, workspaceDefaultServerIds };
3407
3958
  }
@@ -3409,9 +3960,10 @@ async function loadEffectivePolicyContext(
3409
3960
  async function withEffectivePolicy(
3410
3961
  deps: ApiRouteDeps,
3411
3962
  workspaceId: string,
3963
+ subjectId: string,
3412
3964
  session: Session,
3413
3965
  ): Promise<Session> {
3414
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
3966
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
3415
3967
  return sessionWithEffectiveToolPolicy(
3416
3968
  session,
3417
3969
  policy.workspaceServerIds,