@opengeni/api-router 0.22.2 → 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 (124) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/browser-controller-authority.d.ts +43 -0
  5. package/dist/browser-state-authority.d.ts +27 -0
  6. package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
  7. package/dist/chunk-JIKNR5YL.js.map +1 -0
  8. package/dist/codemode.d.ts +23 -0
  9. package/dist/editable-artifact-live-hints.d.ts +11 -0
  10. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  11. package/dist/editable-artifact-office-import.d.ts +22 -0
  12. package/dist/editable-artifact-production.d.ts +29 -0
  13. package/dist/editable-artifact-websocket.d.ts +49 -0
  14. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  15. package/dist/github-browser-flow.d.ts +6 -0
  16. package/dist/http/cors.d.ts +1 -0
  17. package/dist/http/sse.d.ts +2 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1824 -30
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/api-integrations.d.ts +24 -0
  22. package/dist/integrations/atlassian.d.ts +176 -0
  23. package/dist/integrations/github-skill-source.d.ts +5 -0
  24. package/dist/integrations/google-drive.d.ts +85 -0
  25. package/dist/integrations/oauth-client.d.ts +30 -1
  26. package/dist/integrations/provider-oauth.d.ts +19 -0
  27. package/dist/integrations/slack-bot.d.ts +4 -0
  28. package/dist/integrations/slack-interactions.d.ts +14 -2
  29. package/dist/integrations/social-api.d.ts +2 -1
  30. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  31. package/dist/mcp/editable-artifacts.d.ts +13 -0
  32. package/dist/mcp/receipts.d.ts +28 -0
  33. package/dist/mcp/scheduled-task-view.d.ts +518 -0
  34. package/dist/mcp/server.d.ts +14 -3
  35. package/dist/memory-slack-delivery.d.ts +9 -0
  36. package/dist/routes/api-integrations.d.ts +8 -0
  37. package/dist/routes/browser-identities.d.ts +5 -0
  38. package/dist/routes/browser-sessions.d.ts +6 -0
  39. package/dist/routes/company-profile.d.ts +3 -0
  40. package/dist/routes/computer-sessions.d.ts +6 -0
  41. package/dist/routes/editable-artifacts.d.ts +44 -0
  42. package/dist/routes/integration-features.d.ts +3 -0
  43. package/dist/routes/memory-slack-publications.d.ts +6 -0
  44. package/dist/routes/plugins.d.ts +8 -0
  45. package/dist/routes/sessions.d.ts +17 -2
  46. package/dist/routes/skills.d.ts +6 -0
  47. package/dist/routes/video-generation.d.ts +3 -0
  48. package/dist/sandbox/auth-callout.d.ts +2 -0
  49. package/dist/sandbox/channel-a.d.ts +59 -2
  50. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  51. package/dist/sandbox/viewer.d.ts +4 -2
  52. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  53. package/package.json +19 -14
  54. package/src/app.ts +277 -41
  55. package/src/auth/managed-auth.ts +0 -16
  56. package/src/browser-controller-authority.ts +137 -0
  57. package/src/browser-state-authority.ts +236 -0
  58. package/src/codemode.ts +186 -0
  59. package/src/editable-artifact-live-hints.ts +64 -0
  60. package/src/editable-artifact-native-kernel.ts +659 -0
  61. package/src/editable-artifact-office-import.ts +230 -0
  62. package/src/editable-artifact-production.ts +419 -0
  63. package/src/editable-artifact-websocket.ts +311 -0
  64. package/src/editable-artifact-workspace-files.ts +186 -0
  65. package/src/github-browser-flow.ts +35 -6
  66. package/src/http/auth.ts +2 -0
  67. package/src/http/cors.ts +3 -0
  68. package/src/http/sse.ts +101 -6
  69. package/src/index.ts +147 -23
  70. package/src/integrations/api-integrations.ts +350 -0
  71. package/src/integrations/atlassian.ts +1621 -0
  72. package/src/integrations/github-skill-source.ts +142 -0
  73. package/src/integrations/google-drive.ts +1000 -64
  74. package/src/integrations/oauth-client.ts +159 -89
  75. package/src/integrations/provider-oauth.ts +777 -0
  76. package/src/integrations/slack-bot.ts +31 -2
  77. package/src/integrations/slack-interactions.ts +610 -42
  78. package/src/integrations/social-api.ts +11 -0
  79. package/src/mcp/documents.ts +74 -26
  80. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  81. package/src/mcp/editable-artifacts.ts +448 -0
  82. package/src/mcp/receipts.ts +95 -0
  83. package/src/mcp/scheduled-task-view.ts +642 -0
  84. package/src/mcp/server.ts +1718 -310
  85. package/src/memory-slack-delivery.ts +209 -0
  86. package/src/observability.ts +3 -3
  87. package/src/routes/api-integrations.ts +407 -0
  88. package/src/routes/api-keys.ts +7 -1
  89. package/src/routes/browser-identities.ts +136 -0
  90. package/src/routes/browser-sessions.ts +2543 -0
  91. package/src/routes/codex.ts +7 -4
  92. package/src/routes/company-profile.ts +255 -0
  93. package/src/routes/computer-sessions.ts +1247 -0
  94. package/src/routes/connections.ts +358 -102
  95. package/src/routes/documents.ts +22 -3
  96. package/src/routes/editable-artifacts.ts +1159 -0
  97. package/src/routes/enrollments.ts +54 -12
  98. package/src/routes/environments.ts +60 -11
  99. package/src/routes/files.ts +277 -65
  100. package/src/routes/github.ts +18 -2
  101. package/src/routes/install.ts +38 -2
  102. package/src/routes/integration-features.ts +258 -0
  103. package/src/routes/machines.ts +1 -1
  104. package/src/routes/memory-slack-publications.ts +216 -0
  105. package/src/routes/packs.ts +437 -7
  106. package/src/routes/plugins.ts +751 -0
  107. package/src/routes/rigs.ts +77 -20
  108. package/src/routes/scheduled-tasks.ts +94 -42
  109. package/src/routes/sessions.ts +475 -234
  110. package/src/routes/skills.ts +174 -0
  111. package/src/routes/transcription-recordings.ts +65 -33
  112. package/src/routes/video-generation.ts +132 -0
  113. package/src/routes/workspaces.ts +46 -24
  114. package/src/sandbox/auth-callout.ts +16 -4
  115. package/src/sandbox/channel-a.ts +809 -85
  116. package/src/sandbox/enrollment.ts +13 -3
  117. package/src/sandbox/machines.ts +1 -1
  118. package/src/sandbox/metrics-ingestion.ts +121 -3
  119. package/src/sandbox/rematerialize.ts +35 -47
  120. package/src/sandbox/viewer.ts +58 -29
  121. package/src/temporal-schedule-cleanup.ts +135 -0
  122. package/dist/chunk-HWXJW5C7.js.map +0 -1
  123. package/dist/mcp/toolspace.d.ts +0 -62
  124. package/src/mcp/toolspace.ts +0 -1186
@@ -12,6 +12,7 @@ import {
12
12
  EditSessionQueueItemRequest,
13
13
  EndSessionRealtimeRequest,
14
14
  FsDeleteRequest,
15
+ FsListBatchRequest,
15
16
  FsListRequest,
16
17
  FsMkdirRequest,
17
18
  FsMoveRequest,
@@ -19,6 +20,7 @@ import {
19
20
  FsWriteRequest,
20
21
  HumanInputRequestStatus,
21
22
  GitDiffRequest,
23
+ GitReadBatchRequest,
22
24
  GitLogRequest,
23
25
  GitShowRequest,
24
26
  GitStatusRequest,
@@ -53,9 +55,12 @@ import {
53
55
  ViewerHeartbeatRequest,
54
56
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
55
57
  workspaceControlUtf8Bytes,
58
+ type AccessGrant,
59
+ type AttachViewerResponse,
56
60
  type SandboxBackend,
57
61
  type LineageNode,
58
62
  type Session,
63
+ type AgentTopologyPageResponse,
59
64
  type ErrorCode,
60
65
  type SessionAuthorizationOperation,
61
66
  type SessionQueueSnapshot,
@@ -84,6 +89,8 @@ import {
84
89
  listSessionEventPage,
85
90
  listSessionHumanInputRequests,
86
91
  listSessionIdsInGroup,
92
+ listSessionDiscoverySummaries,
93
+ listSessionDiscoveryAncestorPaths,
87
94
  listSessionsForSubject,
88
95
  getLatestStartedSessionTurn,
89
96
  listSessionTurns,
@@ -91,8 +98,8 @@ import {
91
98
  projectSessionForRelatedAccess,
92
99
  recordStreamAcknowledgment,
93
100
  requestSessionCompaction,
94
- setSessionCodexPin,
95
- withCodexCapacityMutation,
101
+ setSessionCodexPinInTransaction,
102
+ withSessionCodexCapacityMutation,
96
103
  setSessionPin,
97
104
  SessionPinVersionConflictError,
98
105
  SessionPinAccessError,
@@ -122,6 +129,7 @@ import {
122
129
  sessionLatestWorkspaceCapture,
123
130
  renewSessionRealtimeInTransaction,
124
131
  syncSessionRealtimeLedgerInTransaction,
132
+ withWorkspaceSessionActivityRls,
125
133
  withWorkspaceRls,
126
134
  workspaceCaptureAtRevision,
127
135
  type AppendEventInput,
@@ -129,6 +137,8 @@ import {
129
137
  type SandboxPtyProcessIdentity,
130
138
  type SandboxRetainedProcess,
131
139
  type Database,
140
+ type SessionDiscoveryCursor,
141
+ type SessionDiscoveryAncestor,
132
142
  } from "@opengeni/db";
133
143
  import {
134
144
  appendAndPublishEvents,
@@ -141,13 +151,22 @@ import {
141
151
  GatewayRealtimeBrokerError,
142
152
  } from "../gateway-realtime";
143
153
  import { z, ZodError } from "zod";
144
- 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";
145
162
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
146
163
  import type { Context, Hono, MiddlewareHandler } from "hono";
147
164
  import { HTTPException } from "hono/http-exception";
148
165
  import type { ContentfulStatusCode } from "hono/utils/http-status";
149
166
  import {
167
+ hasPermission,
150
168
  requireAccessGrant,
169
+ requirePermission,
151
170
  requireSessionAuthorization,
152
171
  requireSessionAuthorizationListScope,
153
172
  SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
@@ -203,6 +222,19 @@ import {
203
222
 
204
223
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
205
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
+
206
238
  export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
207
239
  const { settings, db, bus, workflowClient, objectStorage } = deps;
208
240
  const channelAServices = { db, settings, bus, observability: deps.observability };
@@ -333,6 +365,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
333
365
  const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
334
366
  const workspaceId = c.req.param("workspaceId") ?? "";
335
367
  const sessionId = c.req.param("sessionId") ?? "";
368
+ // Reject malformed route identifiers before the authorization resolver
369
+ // reaches UUID-typed persistence queries. Besides avoiding a needless DB
370
+ // round trip, this preserves the session surface's non-enumerating 404
371
+ // contract instead of leaking a driver-level 500.
372
+ if (!z.string().uuid().safeParse(sessionId).success) {
373
+ throw new HTTPException(404, { message: "session not found" });
374
+ }
336
375
  const operation = sessionAuthorizationOperationForHttp(
337
376
  c.req.method,
338
377
  new URL(c.req.url).pathname,
@@ -397,7 +436,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
397
436
  // Creation has committed by this point. Keep response projection outside
398
437
  // the create-rejection boundary so a post-commit policy read cannot be
399
438
  // misreported as though the session itself was rejected.
400
- return c.json(await withEffectivePolicy(deps, workspaceId, session), 202);
439
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
401
440
  });
402
441
 
403
442
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
@@ -501,7 +540,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
501
540
  // body for older clients while still making its older-pin omission visible
502
541
  // to raw HTTP consumers without changing that response shape.
503
542
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
504
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
543
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
505
544
  const decorate = (session: Session): Session =>
506
545
  sessionWithEffectiveToolPolicy(
507
546
  session,
@@ -523,6 +562,83 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
523
562
  return c.json([...page.pinned, ...page.sessions].map(decorate));
524
563
  });
525
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
+
526
642
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
527
643
  const workspaceId = c.req.param("workspaceId");
528
644
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -540,7 +656,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
540
656
  if (!session) {
541
657
  throw new HTTPException(404, { message: "session not found" });
542
658
  }
543
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
659
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
544
660
  });
545
661
 
546
662
  const publishRealtimeMutation = async (
@@ -579,16 +695,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
579
695
  throw new HTTPException(400, { message: "invalid session realtime request" });
580
696
  }
581
697
  try {
582
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
583
- scopedDb.transaction(async (tx) =>
584
- beginSessionRealtimeInTransaction(tx as unknown as Database, {
585
- accountId: grant.accountId,
586
- workspaceId,
587
- sessionId,
588
- ownerSubjectId: grant.subjectId,
589
- ...parsed.data,
590
- }),
591
- ),
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
+ }),
592
706
  );
593
707
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
594
708
  c.header("cache-control", "private, no-store");
@@ -616,16 +730,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
616
730
  throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
617
731
  }
618
732
  try {
619
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
620
- scopedDb.transaction(async (tx) =>
621
- renewSessionRealtimeInTransaction(tx as unknown as Database, {
622
- workspaceId,
623
- sessionId,
624
- realtimeId,
625
- ownerSubjectId: grant.subjectId,
626
- ...parsed.data,
627
- }),
628
- ),
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
+ }),
629
741
  );
630
742
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
631
743
  c.header("cache-control", "private, no-store");
@@ -652,16 +764,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
652
764
  throw new HTTPException(400, { message: "invalid realtime end request" });
653
765
  }
654
766
  try {
655
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
656
- scopedDb.transaction(async (tx) =>
657
- endSessionRealtimeInTransaction(tx as unknown as Database, {
658
- workspaceId,
659
- sessionId,
660
- realtimeId,
661
- ownerSubjectId: grant.subjectId,
662
- ...parsed.data,
663
- }),
664
- ),
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
+ }),
665
775
  );
666
776
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
667
777
  c.header("cache-control", "private, no-store");
@@ -1024,16 +1134,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1024
1134
  throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
1025
1135
  }
1026
1136
  try {
1027
- const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1028
- scopedDb.transaction(async (tx) =>
1029
- syncSessionRealtimeLedgerInTransaction(tx as unknown as Database, {
1030
- workspaceId,
1031
- sessionId,
1032
- realtimeId,
1033
- ownerSubjectId: grant.subjectId,
1034
- ...parsed.data,
1035
- }),
1036
- ),
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
+ }),
1037
1145
  );
1038
1146
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1039
1147
  c.header("cache-control", "private, no-store");
@@ -1072,6 +1180,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1072
1180
  await withEffectivePolicy(
1073
1181
  deps,
1074
1182
  workspaceId,
1183
+ grant.subjectId,
1075
1184
  projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
1076
1185
  ),
1077
1186
  );
@@ -1096,7 +1205,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1096
1205
  const workspaceId = c.req.param("workspaceId");
1097
1206
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1098
1207
  const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
1099
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
1208
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
1100
1209
  return c.json({
1101
1210
  ...lineage,
1102
1211
  ancestors: lineage.ancestors.map((session) =>
@@ -1138,11 +1247,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1138
1247
  }
1139
1248
  }
1140
1249
  const pinned = target === "auto" ? null : target;
1141
- const mutation = await withCodexCapacityMutation(
1250
+ const mutation = await withSessionCodexCapacityMutation(
1142
1251
  db,
1143
1252
  { workspaceId, reason: "codex_manual_session_pin_changed" },
1144
1253
  async (tx) => {
1145
- const changed = await setSessionCodexPin(tx, workspaceId, sessionId, pinned);
1254
+ const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
1146
1255
  return { result: changed, changed };
1147
1256
  },
1148
1257
  );
@@ -1198,7 +1307,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1198
1307
  if (!session) {
1199
1308
  throw new HTTPException(404, { message: "session not found" });
1200
1309
  }
1201
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1310
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1202
1311
  });
1203
1312
 
1204
1313
  app.patch(
@@ -1236,7 +1345,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1236
1345
  const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
1237
1346
  try {
1238
1347
  const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
1239
- return c.json(await withEffectivePolicy(deps, workspaceId, session));
1348
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1240
1349
  } catch (error) {
1241
1350
  if (error instanceof SessionToolPolicyVersionConflictError) {
1242
1351
  return c.json(
@@ -1342,11 +1451,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1342
1451
  if (event) {
1343
1452
  try {
1344
1453
  await bus.publish(workspaceId, sessionId, [event]);
1345
- } catch (error) {
1346
- console.warn(
1347
- `[api] live publish failed for cleared goal ${workspaceId}/${sessionId}; event is durable and reconciles on replay`,
1348
- error,
1349
- );
1454
+ } catch {
1455
+ console.warn("[api] cleared-goal live publish failed; durable event reconciles on replay", {
1456
+ errorClass: "EventPublishOperationError",
1457
+ errorCode: "cleared_goal_live_publish_failed",
1458
+ origin: "api",
1459
+ });
1350
1460
  }
1351
1461
  }
1352
1462
  return c.body(null, 204);
@@ -1552,6 +1662,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1552
1662
  const projected = compact ? coalesceSessionEventDeltas(events) : events;
1553
1663
  const page = boundSessionEventHttpPage(projected, {
1554
1664
  direction,
1665
+ eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
1555
1666
  });
1556
1667
  const hasMore = dbPage.hasMore || page.truncated;
1557
1668
  c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
@@ -1834,10 +1945,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1834
1945
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1835
1946
  const sessionId = c.req.param("sessionId");
1836
1947
  await assertSessionExists(db, workspaceId, sessionId);
1837
- const raw = await c.req.json();
1838
- const payload = SteerSessionMessageRequest.parse(raw);
1948
+ const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
1839
1949
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1840
1950
  text: payload.text,
1951
+ annotations: payload.annotations,
1841
1952
  turnInstructions: payload.turnInstructions ?? null,
1842
1953
  resources: payload.resources,
1843
1954
  model: payload.model ?? null,
@@ -1859,8 +1970,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1859
1970
  const workspaceId = c.req.param("workspaceId");
1860
1971
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1861
1972
  const sessionId = c.req.param("sessionId");
1862
- const rawEvent = await c.req.json();
1863
- const event = ClientSessionEvent.parse(rawEvent);
1973
+ const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
1864
1974
  const refinedOperation =
1865
1975
  event.type === "user.approvalDecision"
1866
1976
  ? "session.approval.write"
@@ -1881,6 +1991,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1881
1991
  if (event.type === "user.message") {
1882
1992
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
1883
1993
  text: event.payload.text,
1994
+ annotations: event.payload.annotations,
1884
1995
  turnInstructions: event.payload.turnInstructions ?? null,
1885
1996
  resources: event.payload.resources ?? [],
1886
1997
  model: event.payload.model ?? null,
@@ -2045,8 +2156,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2045
2156
  // GET .../stream-capabilities — the capability-negotiation read. Returns the
2046
2157
  // SessionCapabilities doc (descriptor + lease liveness/epoch + os + the
2047
2158
  // shared-exposure disclosure + the calling principal's acknowledgment state),
2048
- // API-direct. The desktop URL/token stay null until P4 mints them (gated by
2049
- // 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.
2050
2161
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
2051
2162
  const workspaceId = c.req.param("workspaceId");
2052
2163
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2063,12 +2174,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2063
2174
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
2064
2175
  const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
2065
2176
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
2066
- // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
2067
- // ADDITIONALLY requires the shared-exposure ack. Both must match the POST
2068
- // /viewers gate EXACTLY otherwise a principal who recorded shared consent
2069
- // WITHOUT un-redacted consent could be handed a live VNC URL + scoped token
2070
- // from this read path while being correctly 409'd on attach (a consent-gate
2071
- // 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.
2072
2180
  const ack = await getStreamAcknowledgment(db, {
2073
2181
  workspaceId,
2074
2182
  sandboxGroupId: session.sandboxGroupId,
@@ -2078,65 +2186,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2078
2186
  ? ack.acknowledgedUnredacted && (!shared || ack.acknowledgedShared)
2079
2187
  : false;
2080
2188
 
2081
- // P4.2 the pixel DATA PLANE, served API-direct. When the backend is
2082
- // desktop-capable AND sandboxDesktopEnabled AND the (shared, if shared)
2083
- // acknowledgment is present AND the box is WARM, mint the REAL DesktopStream
2084
- // cell IN-PROCESS: resume the box by id, ensureDisplayStack (idempotent),
2085
- // exposeStreamPort (resolve the 6080 tunnel + mint the scoped token), record
2086
- // data_plane_url under the epoch fence, and emit stream.url.rotated to other
2087
- // viewers on a box rollover. The handshake never SPINS UP a cold box (that is
2088
- // the viewer-attach path) — a cold lease stays lease_cold. A degraded mint
2089
- // (no secret / display-stack or tunnel failure) returns null → transport:null.
2090
- let desktopStream: DesktopStreamMint | null = null;
2091
- const desktopUnlocked =
2092
- settings.sandboxDesktopEnabled &&
2093
- !streamTokenDegraded(settings) &&
2094
- acknowledged &&
2095
- (session.activeSandboxId != null ||
2096
- lease?.liveness === "warm" ||
2097
- lease?.liveness === "draining");
2098
- if (desktopUnlocked) {
2099
- desktopStream = await mintDesktopStream(
2100
- { db, settings, bus },
2101
- {
2102
- accountId: grant.accountId,
2103
- workspaceId,
2104
- session,
2105
- // The handshake's token is scoped to the calling principal (it is a read,
2106
- // not a viewer-holder acquire); the per-holder token is re-minted on
2107
- // POST /viewers. A previousEpoch != current would have rotated already
2108
- // via the warming-commit; the read does not itself drive rotation.
2109
- viewerId: grant.subjectId,
2110
- ...(lease ? { lease } : {}),
2111
- },
2112
- );
2113
- }
2114
-
2115
- // P5.t — the REAL PTY terminal cell, served API-DIRECT. Independent of the
2116
- // desktop: it gates ONLY on sandboxTerminalEnabled + a real-PTY backend + a
2117
- // WARM box (NO un-redacted ack — the terminal cell has no acknowledgment
2118
- // gate). A degraded mint (terminal off / no secret / ttyd or tunnel failure)
2119
- // returns null → the Terminal cell falls back to the sse-events firehose.
2120
- let terminalStream: TerminalStreamMint | null = null;
2121
- const terminalUnlocked =
2122
- settings.sandboxTerminalEnabled &&
2123
- !streamTokenDegraded(settings) &&
2124
- (session.activeSandboxId != null ||
2125
- lease?.liveness === "warm" ||
2126
- lease?.liveness === "draining");
2127
- if (terminalUnlocked) {
2128
- terminalStream = await mintTerminalStream(
2129
- { db, settings, bus },
2130
- {
2131
- accountId: grant.accountId,
2132
- workspaceId,
2133
- session,
2134
- viewerId: grant.subjectId,
2135
- ...(lease ? { lease } : {}),
2136
- },
2137
- );
2138
- }
2139
-
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.
2140
2193
  const capabilities = negotiateCapabilities({
2141
2194
  sessionId,
2142
2195
  backend: session.sandboxBackend as SandboxBackend,
@@ -2163,33 +2216,21 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2163
2216
  desktopAcknowledged: acknowledged,
2164
2217
  shared,
2165
2218
  sharedSessionIds: visibleSharedSessionIds,
2166
- // The minted live address (null when not unlocked/degraded). The resolver
2167
- // only folds it in when the desktop gates pass + the ack is present.
2168
- ...(desktopStream
2169
- ? {
2170
- desktopStream: {
2171
- url: desktopStream.url,
2172
- token: desktopStream.token,
2173
- expiresAt: desktopStream.expiresAt,
2174
- resolution: desktopStream.resolution,
2175
- },
2176
- }
2177
- : {}),
2178
- // P5.t — the terminal policy toggle + the minted pty-ws address. The
2179
- // resolver advertises sse-events (firehose) on a cold/disabled terminal and
2180
- // 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.
2181
2221
  terminalEnabled: settings.sandboxTerminalEnabled,
2182
- ...(terminalStream
2183
- ? {
2184
- terminalStream: {
2185
- url: terminalStream.url,
2186
- token: terminalStream.token,
2187
- expiresAt: terminalStream.expiresAt,
2188
- },
2189
- }
2190
- : {}),
2191
2222
  });
2192
2223
 
2224
+ const repositoryRoots = [
2225
+ ...new Set(
2226
+ session.resources.flatMap((resource) =>
2227
+ resource.kind === "repository" && typeof resource.mountPath === "string"
2228
+ ? [resource.mountPath.replace(/^\/+|\/+$/g, "")]
2229
+ : [],
2230
+ ),
2231
+ ),
2232
+ ].filter(Boolean);
2233
+
2193
2234
  // SWAP-CASE desktop transport (BOTH directions): negotiateCapabilities keyed on
2194
2235
  // the HOME backend, but the pixel plane actually runs on the ACTIVE sandbox — and
2195
2236
  // the two backends use DIFFERENT wire transports. The advertised transport MUST
@@ -2209,7 +2250,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2209
2250
  // active sandbox kind is "selfhosted") — EXACTLY mintDesktopStream's routing. When
2210
2251
  // the desktop is available we set the transport from the ACTIVE sandbox in one
2211
2252
  // place (resolveActiveDesktopTransport), covering BOTH swap directions.
2212
- let responseCapabilities = capabilities;
2253
+ let responseCapabilities = {
2254
+ ...capabilities,
2255
+ Git: {
2256
+ ...capabilities.Git,
2257
+ repos: capabilities.Git.available ? repositoryRoots : [],
2258
+ },
2259
+ };
2213
2260
  if (capabilities.DesktopStream.transport !== null) {
2214
2261
  const activeSandbox = session.activeSandboxId
2215
2262
  ? await getSandbox(db, workspaceId, session.activeSandboxId)
@@ -2219,7 +2266,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2219
2266
  settings.sandboxDesktopInteractive !== false,
2220
2267
  );
2221
2268
  responseCapabilities = {
2222
- ...capabilities,
2269
+ ...responseCapabilities,
2223
2270
  DesktopStream: { ...capabilities.DesktopStream, ...wire },
2224
2271
  };
2225
2272
  }
@@ -2273,7 +2320,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2273
2320
  // when cold).
2274
2321
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers", async (c) => {
2275
2322
  const workspaceId = c.req.param("workspaceId");
2276
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
2323
+ // Authenticate and bind the workspace before parsing. The requested plane
2324
+ // determines the narrower permission below: terminal-only holders must not
2325
+ // require the strictly broader un-redacted Desktop permission.
2326
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2277
2327
  assertOwnershipEnabled();
2278
2328
  const sessionId = c.req.param("sessionId");
2279
2329
  const session = await getSession(db, workspaceId, sessionId);
@@ -2286,19 +2336,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2286
2336
  message: "invalid viewer attach request",
2287
2337
  });
2288
2338
  }
2289
- // Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
2290
- // requires the calling principal's acknowledgment (recorded per group+subject;
2291
- // a shared box additionally needs the shared-exposure consent). A TERMINAL-ONLY
2292
- // warm attach (`desktop:false`, the default) carries NO consent gate — a shell
2293
- // is interactive by nature and the gate is the scoped tunnel URL + stream token
2294
- // — so it warms the box and mints the pty-ws terminal cell without a 409. Gating
2295
- // the terminal attach behind the desktop ack (the bug this fixes) dead-ended the
2296
- // interactive terminal: the box never warmed → the Terminal cell stayed on the
2297
- // read-only sse-events firehose forever ("read only"), and with the desktop tier
2298
- // 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.
2299
2343
  const wantDesktop = parsed.data.desktop ?? false;
2300
- 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.
2301
2360
  if (wantDesktop) {
2361
+ const { shared } = await resolveSharedExposure(workspaceId, session);
2302
2362
  const ack = await getStreamAcknowledgment(db, {
2303
2363
  workspaceId,
2304
2364
  sandboxGroupId: session.sandboxGroupId,
@@ -2341,7 +2401,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2341
2401
  dataPlaneUrl: null,
2342
2402
  };
2343
2403
  if (
2344
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2404
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2405
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2345
2406
  !streamTokenDegraded(settings)
2346
2407
  ) {
2347
2408
  if (wantDesktop && settings.sandboxDesktopEnabled) {
@@ -2353,7 +2414,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2353
2414
  // No Modal lease for selfhosted-active; the mint routes to the relay.
2354
2415
  });
2355
2416
  }
2356
- if (settings.sandboxTerminalEnabled) {
2417
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2357
2418
  terminal = await mintTerminalStream(viewerServices, {
2358
2419
  accountId: grant.accountId,
2359
2420
  workspaceId,
@@ -2368,6 +2429,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2368
2429
  accountId: grant.accountId,
2369
2430
  workspaceId,
2370
2431
  session,
2432
+ waitSignal: c.req.raw.signal,
2371
2433
  ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
2372
2434
  });
2373
2435
 
@@ -2379,7 +2441,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2379
2441
  // box is warm here (attachViewer spun it up or attached), so the handshake's
2380
2442
  // never-spin-up rule does not apply.
2381
2443
  if (
2382
- (settings.sandboxDesktopEnabled || settings.sandboxTerminalEnabled) &&
2444
+ ((wantDesktop && settings.sandboxDesktopEnabled) ||
2445
+ (wantTerminal && settings.sandboxTerminalEnabled)) &&
2383
2446
  !streamTokenDegraded(settings)
2384
2447
  ) {
2385
2448
  const lease = await readGroupLease(
@@ -2387,9 +2450,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2387
2450
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
2388
2451
  );
2389
2452
  if (lease) {
2390
- // The pixel cell is minted only when the caller asked for the desktop plane
2391
- // (and consented above). A terminal-only attach skips it the box is warm,
2392
- // 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.
2393
2455
  if (wantDesktop && settings.sandboxDesktopEnabled) {
2394
2456
  stream = await mintDesktopStream(viewerServices, {
2395
2457
  accountId: grant.accountId,
@@ -2399,10 +2461,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2399
2461
  lease,
2400
2462
  });
2401
2463
  }
2402
- // P5.t the same warm-box viewer attach also mints the REAL PTY terminal
2403
- // address (independent of the desktop toggle). A degraded mint leaves the
2404
- // terminal fields null → the client falls back to the sse-events firehose.
2405
- if (settings.sandboxTerminalEnabled) {
2464
+ if (wantTerminal && settings.sandboxTerminalEnabled) {
2406
2465
  terminal = await mintTerminalStream(viewerServices, {
2407
2466
  accountId: grant.accountId,
2408
2467
  workspaceId,
@@ -2414,41 +2473,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2414
2473
  }
2415
2474
  }
2416
2475
  }
2417
- return c.json(
2418
- {
2419
- ...result,
2420
- dataPlaneUrl: stream?.url ?? result.dataPlaneUrl,
2421
- streamToken: stream?.token ?? null,
2422
- streamExpiresAt: stream?.expiresAt ?? null,
2423
- resolution: stream?.resolution ?? null,
2424
- // Transport MUST match where the pixels were minted: a selfhosted-active box
2425
- // serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
2426
- // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
2427
- // noVNC renderer (and vice-versa on the swap-away case) "closed before it
2428
- // opened". Key off the SAME selfhostedActive the mint routed on.
2429
- transport: stream
2430
- ? selfhostedActive
2431
- ? ("relay-frames" as const)
2432
- : ("vnc-ws" as const)
2433
- : null,
2434
- client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
2435
- // The REAL PTY terminal address (pty-ws), null when degraded.
2436
- terminalUrl: terminal?.url ?? null,
2437
- terminalToken: terminal?.token ?? null,
2438
- terminalExpiresAt: terminal?.expiresAt ?? null,
2439
- terminalTransport: terminal ? ("pty-ws" as const) : null,
2440
- },
2441
- 201,
2442
- );
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);
2443
2500
  });
2444
2501
 
2445
2502
  // POST .../viewers/:viewerId/heartbeat — refresh the holder TTL (epoch-fenced).
2446
- // The desktop-stream lifecycle is gated on stream:view (the un-redacted plane).
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.
2447
2506
  app.post(
2448
2507
  "/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat",
2449
2508
  async (c) => {
2450
2509
  const workspaceId = c.req.param("workspaceId");
2451
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
2510
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2511
+ requireViewerLifecyclePermission(grant);
2452
2512
  assertOwnershipEnabled();
2453
2513
  const sessionId = c.req.param("sessionId");
2454
2514
  const session = await getSession(db, workspaceId, sessionId);
@@ -2478,7 +2538,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2478
2538
  // DELETE .../viewers/:viewerId — release the holder (idempotent).
2479
2539
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
2480
2540
  const workspaceId = c.req.param("workspaceId");
2481
- const grant = await requireAccessGrant(c, deps, workspaceId, "stream:view");
2541
+ const grant = await requireAccessGrant(c, deps, workspaceId);
2542
+ requireViewerLifecyclePermission(grant);
2482
2543
  assertOwnershipEnabled();
2483
2544
  const sessionId = c.req.param("sessionId");
2484
2545
  const session = await getSession(db, workspaceId, sessionId);
@@ -2545,11 +2606,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2545
2606
  // FS uses files:read for reads, files:write for mutations; Git is read-only
2546
2607
  // (rides files:read); Terminal exec + PTY ride terminal:attach.
2547
2608
 
2548
- type ChannelARouteCtx = {
2549
- accountId: string;
2550
- workspaceId: string;
2551
- session: Session;
2552
- subjectId: string;
2609
+ type ChannelARouteCtx = ChannelAContext & {
2610
+ waitSignal: AbortSignal;
2611
+ operation: ChannelAOperation;
2553
2612
  };
2554
2613
 
2555
2614
  // Shared preamble: grant BEFORE parse, ownership gate, session lookup. Returns
@@ -2557,6 +2616,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2557
2616
  async function channelAPreamble(
2558
2617
  c: Context,
2559
2618
  permission: "files:read" | "files:write" | "terminal:attach",
2619
+ operation: ChannelAOperation,
2560
2620
  ): Promise<ChannelARouteCtx> {
2561
2621
  const workspaceId = c.req.param("workspaceId") ?? "";
2562
2622
  const grant = await requireAccessGrant(c, deps, workspaceId, permission);
@@ -2571,6 +2631,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2571
2631
  workspaceId,
2572
2632
  session,
2573
2633
  subjectId: grant.subjectId,
2634
+ waitSignal: c.req.raw.signal,
2635
+ operation,
2574
2636
  };
2575
2637
  }
2576
2638
 
@@ -2590,42 +2652,53 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2590
2652
 
2591
2653
  // ── FileSystem ──────────────────────────────────────────────────────────
2592
2654
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
2593
- const ctx = await channelAPreamble(c, "files:read");
2655
+ const ctx = await channelAPreamble(c, "files:read", "fs.list");
2594
2656
  const req = await parseChannelABody(c, FsListRequest);
2595
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsList(req));
2657
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsList(req));
2658
+ return c.json(out);
2659
+ });
2660
+
2661
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list-batch", async (c) => {
2662
+ const ctx = await channelAPreamble(c, "files:read", "fs.list-batch");
2663
+ const req = await parseChannelABody(c, FsListBatchRequest);
2664
+ const out = await withChannelARead(channelAServices, ctx, async ({ service }) => ({
2665
+ results: await runConcurrentChannelAReads(
2666
+ req.requests.map((request) => async () => await service.fsList(request)),
2667
+ ),
2668
+ }));
2596
2669
  return c.json(out);
2597
2670
  });
2598
2671
 
2599
2672
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
2600
- const ctx = await channelAPreamble(c, "files:read");
2673
+ const ctx = await channelAPreamble(c, "files:read", "fs.read");
2601
2674
  const req = await parseChannelABody(c, FsReadRequest);
2602
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsRead(req));
2675
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.fsRead(req));
2603
2676
  return c.json(out);
2604
2677
  });
2605
2678
 
2606
2679
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
2607
- const ctx = await channelAPreamble(c, "files:write");
2680
+ const ctx = await channelAPreamble(c, "files:write", "fs.write");
2608
2681
  const req = await parseChannelABody(c, FsWriteRequest);
2609
2682
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
2610
2683
  return c.json(out);
2611
2684
  });
2612
2685
 
2613
2686
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
2614
- const ctx = await channelAPreamble(c, "files:write");
2687
+ const ctx = await channelAPreamble(c, "files:write", "fs.delete");
2615
2688
  const req = await parseChannelABody(c, FsDeleteRequest);
2616
2689
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
2617
2690
  return c.json(out);
2618
2691
  });
2619
2692
 
2620
2693
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
2621
- const ctx = await channelAPreamble(c, "files:write");
2694
+ const ctx = await channelAPreamble(c, "files:write", "fs.move");
2622
2695
  const req = await parseChannelABody(c, FsMoveRequest);
2623
2696
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
2624
2697
  return c.json(out);
2625
2698
  });
2626
2699
 
2627
2700
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
2628
- const ctx = await channelAPreamble(c, "files:write");
2701
+ const ctx = await channelAPreamble(c, "files:write", "fs.mkdir");
2629
2702
  const req = await parseChannelABody(c, FsMkdirRequest);
2630
2703
  const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
2631
2704
  return c.json(out);
@@ -2633,30 +2706,87 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2633
2706
 
2634
2707
  // ── Git (read-only) ─────────────────────────────────────────────────────
2635
2708
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
2636
- const ctx = await channelAPreamble(c, "files:read");
2709
+ const ctx = await channelAPreamble(c, "files:read", "git.status");
2637
2710
  const req = await parseChannelABody(c, GitStatusRequest);
2638
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitStatus(req));
2711
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2712
+ service.gitStatus(req),
2713
+ );
2639
2714
  return c.json(out);
2640
2715
  });
2641
2716
 
2642
2717
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
2643
- const ctx = await channelAPreamble(c, "files:read");
2718
+ const ctx = await channelAPreamble(c, "files:read", "git.diff");
2644
2719
  const req = await parseChannelABody(c, GitDiffRequest);
2645
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitDiff(req));
2720
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2721
+ service.gitDiff(req),
2722
+ );
2723
+ return c.json(out);
2724
+ });
2725
+
2726
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/read-batch", async (c) => {
2727
+ const ctx = await channelAPreamble(c, "files:read", "git.read-batch");
2728
+ const req = await parseChannelABody(c, GitReadBatchRequest);
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) {
2743
+ const diffRequest = request.diff;
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
+ }
2770
+ return { status, ...(diff ? { diff } : {}) };
2771
+ }),
2772
+ };
2773
+ });
2646
2774
  return c.json(out);
2647
2775
  });
2648
2776
 
2649
2777
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
2650
- const ctx = await channelAPreamble(c, "files:read");
2778
+ const ctx = await channelAPreamble(c, "files:read", "git.log");
2651
2779
  const req = await parseChannelABody(c, GitLogRequest);
2652
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitLog(req));
2780
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) => service.gitLog(req));
2653
2781
  return c.json(out);
2654
2782
  });
2655
2783
 
2656
2784
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
2657
- const ctx = await channelAPreamble(c, "files:read");
2785
+ const ctx = await channelAPreamble(c, "files:read", "git.show");
2658
2786
  const req = await parseChannelABody(c, GitShowRequest);
2659
- const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitShow(req));
2787
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
2788
+ service.gitShow(req),
2789
+ );
2660
2790
  return c.json(out);
2661
2791
  });
2662
2792
 
@@ -2719,7 +2849,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2719
2849
 
2720
2850
  // ── Terminal: synchronous exec ────────────────────────────────────────────
2721
2851
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/exec", async (c) => {
2722
- const ctx = await channelAPreamble(c, "terminal:attach");
2852
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.exec");
2723
2853
  const req = await parseChannelABody(c, TerminalExecRequest);
2724
2854
  const out = await withChannelA(channelAServices, ctx, ({ service }) =>
2725
2855
  service.terminalExec(req),
@@ -2729,7 +2859,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2729
2859
 
2730
2860
  // ── Terminal: interactive PTY control (output rides A1) ───────────────────
2731
2861
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty", async (c) => {
2732
- const ctx = await channelAPreamble(c, "terminal:attach");
2862
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.open");
2733
2863
  const req = await parseChannelABody(c, PtyOpenRequest);
2734
2864
  if (ctx.session.sandboxBackend === "selfhosted" || ctx.session.activeSandboxId !== null) {
2735
2865
  throw new HTTPException(409, {
@@ -2838,7 +2968,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2838
2968
  });
2839
2969
 
2840
2970
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/write", async (c) => {
2841
- const ctx = await channelAPreamble(c, "terminal:attach");
2971
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.write");
2842
2972
  const req = await parseChannelABody(c, PtyWriteRequest);
2843
2973
  const pty = await getOpenPtySession(db, {
2844
2974
  workspaceId: ctx.workspaceId,
@@ -2902,7 +3032,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2902
3032
  });
2903
3033
 
2904
3034
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/resize", async (c) => {
2905
- const ctx = await channelAPreamble(c, "terminal:attach");
3035
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.resize");
2906
3036
  const req = await parseChannelABody(c, PtyResizeRequest);
2907
3037
  const pty = await getOpenPtySession(db, {
2908
3038
  workspaceId: ctx.workspaceId,
@@ -2934,7 +3064,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2934
3064
  });
2935
3065
 
2936
3066
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/terminal/pty/close", async (c) => {
2937
- const ctx = await channelAPreamble(c, "terminal:attach");
3067
+ const ctx = await channelAPreamble(c, "terminal:attach", "terminal.pty.close");
2938
3068
  const req = await parseChannelABody(c, PtyCloseRequest);
2939
3069
  const pty = await getOpenPtySession(db, {
2940
3070
  workspaceId: ctx.workspaceId,
@@ -3101,7 +3231,7 @@ export function sessionAuthorizationOperationForHttp(
3101
3231
  if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
3102
3232
  return "session.viewer.control";
3103
3233
  }
3104
- if (suffix === "/fs/list" || suffix === "/fs/read") {
3234
+ if (suffix === "/fs/list" || suffix === "/fs/list-batch" || suffix === "/fs/read") {
3105
3235
  return verb === "POST" ? "session.files.read" : null;
3106
3236
  }
3107
3237
  if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
@@ -3246,6 +3376,99 @@ function sessionListQuery(
3246
3376
  };
3247
3377
  }
3248
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
+
3249
3472
  function compactEvents(raw: string | undefined): boolean {
3250
3473
  return raw === "1" || raw === "true";
3251
3474
  }
@@ -3298,6 +3521,22 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
3298
3521
  throw error;
3299
3522
  }
3300
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
+
3301
3540
  function zodErrorFields(error: ZodError): string {
3302
3541
  const paths = [
3303
3542
  ...new Set(
@@ -3333,10 +3572,11 @@ type EffectivePolicyContext = {
3333
3572
  async function loadEffectivePolicyContext(
3334
3573
  deps: ApiRouteDeps,
3335
3574
  workspaceId: string,
3575
+ subjectId: string,
3336
3576
  ): Promise<EffectivePolicyContext> {
3337
3577
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
3338
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
3339
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
3578
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
3579
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
3340
3580
  ]);
3341
3581
  return { workspaceServerIds, workspaceDefaultServerIds };
3342
3582
  }
@@ -3344,9 +3584,10 @@ async function loadEffectivePolicyContext(
3344
3584
  async function withEffectivePolicy(
3345
3585
  deps: ApiRouteDeps,
3346
3586
  workspaceId: string,
3587
+ subjectId: string,
3347
3588
  session: Session,
3348
3589
  ): Promise<Session> {
3349
- const policy = await loadEffectivePolicyContext(deps, workspaceId);
3590
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
3350
3591
  return sessionWithEffectiveToolPolicy(
3351
3592
  session,
3352
3593
  policy.workspaceServerIds,