@opengeni/api-router 0.26.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 (87) hide show
  1. package/dist/app.d.ts +1 -0
  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 +19 -1
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
  8. package/dist/chunk-2PQMSRCB.js.map +1 -0
  9. package/dist/controller-data-plane.d.ts +12 -0
  10. package/dist/http/auth.d.ts +1 -1
  11. package/dist/http/sse.d.ts +18 -1
  12. package/dist/index.js +1 -1
  13. package/dist/integrations/api-integrations.d.ts +2 -2
  14. package/dist/integrations/fiken.d.ts +233 -0
  15. package/dist/integrations/google-drive.d.ts +6 -6
  16. package/dist/integrations/provider-oauth.d.ts +0 -2
  17. package/dist/integrations/slack-bot.d.ts +171 -1
  18. package/dist/integrations/slack-interactions.d.ts +17 -5
  19. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  20. package/dist/interaction-metrics.d.ts +11 -0
  21. package/dist/mcp/server.d.ts +3 -2
  22. package/dist/model-catalog.d.ts +1 -0
  23. package/dist/routes/canonical-human-identities.d.ts +3 -0
  24. package/dist/routes/channels.d.ts +3 -0
  25. package/dist/routes/integration-facets.d.ts +3 -0
  26. package/dist/routes/interaction-resources.d.ts +5 -0
  27. package/dist/routes/organization-memberships.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +2 -1
  29. package/dist/routes/slack-task-policy.d.ts +3 -0
  30. package/dist/routes/supergrok.d.ts +3 -0
  31. package/dist/sandbox/channel-a.d.ts +8 -1
  32. package/dist/sandbox/machines.d.ts +2 -2
  33. package/dist/sandbox/viewer.d.ts +3 -1
  34. package/dist/slack-reaction-files.d.ts +27 -0
  35. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  36. package/dist/xai-realtime.d.ts +26 -0
  37. package/dist/xai-subscription-auth.d.ts +23 -0
  38. package/package.json +17 -16
  39. package/src/app.ts +352 -49
  40. package/src/auth/canonical-human-session-admission.ts +116 -0
  41. package/src/auth/managed-auth.ts +101 -0
  42. package/src/browser-auth-broker.ts +155 -0
  43. package/src/browser-controller-authority.ts +50 -1
  44. package/src/browser-network-route.ts +34 -0
  45. package/src/controller-data-plane.ts +35 -0
  46. package/src/http/auth.ts +27 -9
  47. package/src/http/sse.ts +229 -5
  48. package/src/integrations/api-integrations.ts +57 -42
  49. package/src/integrations/fiken.ts +1230 -0
  50. package/src/integrations/google-drive.ts +31 -26
  51. package/src/integrations/provider-oauth.ts +70 -91
  52. package/src/integrations/slack-bot.ts +638 -9
  53. package/src/integrations/slack-interactions.ts +1583 -73
  54. package/src/interaction-holder-heartbeat.ts +95 -0
  55. package/src/interaction-metrics.ts +159 -0
  56. package/src/mcp/documents.ts +115 -6
  57. package/src/mcp/server.ts +865 -242
  58. package/src/model-catalog.ts +19 -6
  59. package/src/routes/api-integrations.ts +32 -29
  60. package/src/routes/browser-identities.ts +24 -0
  61. package/src/routes/browser-sessions.ts +2122 -183
  62. package/src/routes/canonical-human-identities.ts +156 -0
  63. package/src/routes/channels.ts +90 -0
  64. package/src/routes/computer-sessions.ts +441 -86
  65. package/src/routes/connections.ts +141 -4
  66. package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
  67. package/src/routes/interaction-resources.ts +595 -0
  68. package/src/routes/organization-memberships.ts +53 -0
  69. package/src/routes/plugins.ts +2 -1
  70. package/src/routes/sessions.ts +419 -43
  71. package/src/routes/skills.ts +92 -10
  72. package/src/routes/slack-task-policy.ts +115 -0
  73. package/src/routes/supergrok.ts +717 -0
  74. package/src/routes/transcription-recordings.ts +9 -2
  75. package/src/routes/transcriptions.ts +2 -1
  76. package/src/routes/video-generation.ts +34 -4
  77. package/src/routes/workspaces.ts +22 -4
  78. package/src/sandbox/channel-a.ts +29 -2
  79. package/src/sandbox/machines.ts +5 -5
  80. package/src/sandbox/viewer.ts +10 -3
  81. package/src/slack-reaction-files.ts +181 -0
  82. package/src/transcription/providers/xai-subscription.ts +110 -0
  83. package/src/transcription/service.ts +17 -2
  84. package/src/xai-realtime.ts +216 -0
  85. package/src/xai-subscription-auth.ts +132 -0
  86. package/dist/chunk-JIKNR5YL.js.map +0 -1
  87. package/dist/routes/integration-features.d.ts +0 -3
@@ -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,
@@ -60,6 +62,7 @@ import {
60
62
  type SandboxBackend,
61
63
  type LineageNode,
62
64
  type Session,
65
+ type SessionGoalRevision,
63
66
  type AgentTopologyPageResponse,
64
67
  type ErrorCode,
65
68
  type SessionAuthorizationOperation,
@@ -75,6 +78,7 @@ import {
75
78
  clearSessionGoal,
76
79
  clearSessionContext,
77
80
  getOpenPtySession,
81
+ getEnrollment,
78
82
  getRetainedProcess,
79
83
  getSandbox,
80
84
  getSession,
@@ -83,11 +87,13 @@ import {
83
87
  getSessionGoal,
84
88
  getSessionHumanInputRequest,
85
89
  getSessionGoalWithContinuation,
90
+ getSessionGoalRevision,
86
91
  getSessionQueueSnapshot,
87
92
  getStreamAcknowledgment,
88
93
  insertPtySession,
89
94
  listSessionEventPage,
90
95
  listSessionHumanInputRequests,
96
+ listSessionGoalRevisions,
91
97
  listSessionIdsInGroup,
92
98
  listSessionDiscoverySummaries,
93
99
  listSessionDiscoveryAncestorPaths,
@@ -100,6 +106,8 @@ import {
100
106
  requestSessionCompaction,
101
107
  setSessionCodexPinInTransaction,
102
108
  withSessionCodexCapacityMutation,
109
+ setSessionChannel,
110
+ ChannelNotFoundError,
103
111
  setSessionPin,
104
112
  SessionPinVersionConflictError,
105
113
  SessionPinAccessError,
@@ -110,6 +118,7 @@ import {
110
118
  decodeSessionListCursor,
111
119
  revokeViewer,
112
120
  setSessionGoalStatusWithEvent,
121
+ upsertSessionGoalWithEvent,
113
122
  updatePtySessionActivity,
114
123
  QueueCommandConflictError,
115
124
  beginSessionRealtimeInTransaction,
@@ -150,6 +159,7 @@ import {
150
159
  createGatewayRealtimeConnectionSecret,
151
160
  GatewayRealtimeBrokerError,
152
161
  } from "../gateway-realtime";
162
+ import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
153
163
  import { z, ZodError } from "zod";
154
164
  import {
155
165
  runConcurrentChannelAReads,
@@ -159,7 +169,12 @@ import {
159
169
  type ChannelAHandle,
160
170
  type ChannelAOperation,
161
171
  } from "../sandbox/channel-a";
162
- import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
172
+ import {
173
+ NatsControlRpc,
174
+ negotiateCapabilities,
175
+ negotiateSelfhostedCapabilities,
176
+ SelfhostedSession,
177
+ } from "@opengeni/runtime/sandbox";
163
178
  import type { Context, Hono, MiddlewareHandler } from "hono";
164
179
  import { HTTPException } from "hono/http-exception";
165
180
  import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -169,6 +184,7 @@ import {
169
184
  requirePermission,
170
185
  requireSessionAuthorization,
171
186
  requireSessionAuthorizationListScope,
187
+ withResolvedSessionAuthorization,
172
188
  SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
173
189
  SessionAuthorizationDeniedError,
174
190
  SessionAuthorizationUnavailableError,
@@ -211,6 +227,7 @@ import {
211
227
  sessionWithEffectiveToolPolicy,
212
228
  workspaceSessionToolPolicyDefaultServerIds,
213
229
  workspaceSessionToolPolicyServerIds,
230
+ relayConfigFromSettings,
214
231
  } from "@opengeni/core";
215
232
  import { assertSessionExists, boundedLimit } from "../http/common";
216
233
  import { sseSessionStream } from "../http/sse";
@@ -237,7 +254,12 @@ function requireViewerLifecyclePermission(grant: AccessGrant): void {
237
254
 
238
255
  export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
239
256
  const { settings, db, bus, workflowClient, objectStorage } = deps;
240
- const channelAServices = { db, settings, bus, observability: deps.observability };
257
+ const channelAServices = {
258
+ db,
259
+ settings,
260
+ bus,
261
+ observability: deps.observability,
262
+ };
241
263
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
242
264
  const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
243
265
  leaseId: pty.leaseId,
@@ -384,10 +406,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
384
406
  if (!operation) {
385
407
  throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
386
408
  }
387
- if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
388
- await next();
389
- return;
390
- }
391
409
  const grant = await requireAccessGrant(c, deps, workspaceId);
392
410
  try {
393
411
  const authorization = await requireSessionAuthorization(deps, grant, {
@@ -396,6 +414,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
396
414
  surface: "http",
397
415
  });
398
416
  if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
417
+ if (authorization) {
418
+ await withResolvedSessionAuthorization(authorization, next);
419
+ return;
420
+ }
399
421
  } catch (error) {
400
422
  throw sessionAuthorizationHttpError(error);
401
423
  }
@@ -692,7 +714,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
692
714
  }
693
715
  const parsed = BeginSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
694
716
  if (!parsed.success) {
695
- throw new HTTPException(400, { message: "invalid session realtime request" });
717
+ throw new HTTPException(400, {
718
+ message: "invalid session realtime request",
719
+ });
696
720
  }
697
721
  try {
698
722
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
@@ -723,11 +747,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
723
747
  !z.string().uuid().safeParse(sessionId).success ||
724
748
  !z.string().uuid().safeParse(realtimeId).success
725
749
  ) {
726
- throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
750
+ throw new HTTPException(400, {
751
+ message: "invalid realtime lifecycle id",
752
+ });
727
753
  }
728
754
  const parsed = RenewSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
729
755
  if (!parsed.success) {
730
- throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
756
+ throw new HTTPException(400, {
757
+ message: "invalid realtime heartbeat request",
758
+ });
731
759
  }
732
760
  try {
733
761
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
@@ -757,11 +785,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
757
785
  !z.string().uuid().safeParse(sessionId).success ||
758
786
  !z.string().uuid().safeParse(realtimeId).success
759
787
  ) {
760
- throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
788
+ throw new HTTPException(400, {
789
+ message: "invalid realtime lifecycle id",
790
+ });
761
791
  }
762
792
  const parsed = EndSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
763
793
  if (!parsed.success) {
764
- throw new HTTPException(400, { message: "invalid realtime end request" });
794
+ throw new HTTPException(400, {
795
+ message: "invalid realtime end request",
796
+ });
765
797
  }
766
798
  try {
767
799
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
@@ -874,7 +906,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
874
906
  deps.codexFetch,
875
907
  );
876
908
  try {
877
- 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
+ });
878
913
  const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
879
914
  scopedDb.transaction(async (tx) =>
880
915
  completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
@@ -967,7 +1002,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
967
1002
  }
968
1003
  const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
969
1004
  if (!parsed.success) {
970
- throw new HTTPException(422, { message: "invalid Gateway realtime request" });
1005
+ throw new HTTPException(422, {
1006
+ message: "invalid Gateway realtime request",
1007
+ });
971
1008
  }
972
1009
  c.header("cache-control", "private, no-store");
973
1010
  const {
@@ -1072,6 +1109,121 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1072
1109
  }
1073
1110
  });
1074
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
+
1075
1227
  app.post(
1076
1228
  "/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/connections/:connectionId/activate",
1077
1229
  async (c) => {
@@ -1085,13 +1237,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1085
1237
  !z.string().uuid().safeParse(realtimeId).success ||
1086
1238
  !z.string().uuid().safeParse(connectionId).success
1087
1239
  ) {
1088
- throw new HTTPException(400, { message: "invalid realtime connection id" });
1240
+ throw new HTTPException(400, {
1241
+ message: "invalid realtime connection id",
1242
+ });
1089
1243
  }
1090
1244
  const parsed = ActivateCodexRealtimeConnectionRequest.safeParse(
1091
1245
  await c.req.json().catch(() => null),
1092
1246
  );
1093
1247
  if (!parsed.success) {
1094
- throw new HTTPException(422, { message: "invalid realtime connection activation" });
1248
+ throw new HTTPException(422, {
1249
+ message: "invalid realtime connection activation",
1250
+ });
1095
1251
  }
1096
1252
  try {
1097
1253
  const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
@@ -1131,7 +1287,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1131
1287
  await c.req.json().catch(() => null),
1132
1288
  );
1133
1289
  if (!parsed.success) {
1134
- throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
1290
+ throw new HTTPException(422, {
1291
+ message: "invalid realtime ledger sync request",
1292
+ });
1135
1293
  }
1136
1294
  try {
1137
1295
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
@@ -1284,6 +1442,43 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1284
1442
  return c.json({ pinned: target === "auto" ? "auto" : target });
1285
1443
  });
1286
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
+
1287
1482
  // Manual rename. A user-set title is permanent: the db write is
1288
1483
  // unconditional (source='user'), so it always pins the session over later
1289
1484
  // agent writes. Returns the refreshed session, mirroring GET detail.
@@ -1373,6 +1568,77 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1373
1568
  return c.json(goal);
1374
1569
  });
1375
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
+
1376
1642
  app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
1377
1643
  const workspaceId = c.req.param("workspaceId");
1378
1644
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -1383,6 +1649,48 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1383
1649
  if (!existing) {
1384
1650
  throw new HTTPException(404, { message: "session goal not found" });
1385
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
+ }
1386
1694
  if (existing.status === "completed") {
1387
1695
  throw new HTTPException(409, {
1388
1696
  message: "session goal is completed; set a new goal instead",
@@ -2167,6 +2475,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2167
2475
  if (!session) {
2168
2476
  throw new HTTPException(404, { message: "session not found" });
2169
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";
2170
2487
  const lease = await readGroupLease(
2171
2488
  { db, settings },
2172
2489
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
@@ -2190,36 +2507,64 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2190
2507
  // startup, port exposure, or short-lived bearer mint. Besides enforcing least
2191
2508
  // privilege, that keeps the 120-second stream credentials from aging before a
2192
2509
  // user opens their surface. POST /viewers is the sole credential grant.
2193
- const capabilities = negotiateCapabilities({
2510
+ const commonNegotiation = {
2194
2511
  sessionId,
2195
- backend: session.sandboxBackend as SandboxBackend,
2196
- os: session.sandboxOs,
2197
- liveness: lease?.liveness ?? "cold",
2198
- leaseEpoch: lease?.leaseEpoch ?? 0,
2199
- workspaceGeneration: lease?.workspaceGeneration ?? null,
2200
- archiveGeneration: lease?.archiveGeneration ?? null,
2201
- archiveComplete: lease?.archiveComplete ?? false,
2202
2512
  desktopEnabled: settings.sandboxDesktopEnabled,
2203
2513
  // Human take-control: when the desktop is available + this policy is on
2204
2514
  // (default), the cell is mode "interactive" — the noVNC viewer drives :0
2205
2515
  // (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
2206
2516
  // take-control). Independent of the agent's computerUseReadOnly.
2207
2517
  desktopInteractive: settings.sandboxDesktopInteractive,
2208
- // P4.3 computer-use: the agent drives :0 (xdotool/scrot); availability
2518
+ // P4.3 computer-use: the agent drives the active display; availability
2209
2519
  // tracks the desktop tier + a desktop-capable backend.
2210
2520
  computerUseEnabled: settings.computerUseEnabled,
2211
2521
  computerUseReadOnly: settings.computerUseReadOnly,
2212
- // Graceful degrade (stream-token availability contract): if desktop is enabled but no stream-token
2213
- // secret is resolvable, the desktop cell reports transport:null rather
2214
- // than advertising a plane we can never authorize.
2522
+ // Graceful degrade when scoped stream credentials cannot be minted.
2215
2523
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
2216
2524
  desktopAcknowledged: acknowledged,
2217
2525
  shared,
2218
2526
  sharedSessionIds: visibleSharedSessionIds,
2219
- // Plane policy only. Live addresses are intentionally absent on a
2220
- // descriptor read and arrive from an authorized viewer grant.
2221
2527
  terminalEnabled: settings.sandboxTerminalEnabled,
2222
- });
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
+ }
2223
2568
 
2224
2569
  const repositoryRoots = [
2225
2570
  ...new Set(
@@ -2258,11 +2603,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2258
2603
  },
2259
2604
  };
2260
2605
  if (capabilities.DesktopStream.transport !== null) {
2261
- const activeSandbox = session.activeSandboxId
2262
- ? await getSandbox(db, workspaceId, session.activeSandboxId)
2263
- : null;
2264
2606
  const wire = resolveActiveDesktopTransport(
2265
- activeSandbox?.kind === "selfhosted",
2607
+ selfhostedActive,
2266
2608
  settings.sandboxDesktopInteractive !== false,
2267
2609
  );
2268
2610
  responseCapabilities = {
@@ -2348,7 +2690,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2348
2690
  // v2 flag switches to exact-plane semantics.
2349
2691
  const wantTerminal = parsed.data.terminal ?? (!hasExactPlaneSet && !wantDesktop);
2350
2692
  if (!wantDesktop && !wantTerminal && !wantFiles) {
2351
- throw new HTTPException(400, { message: "viewer attach requires a live plane" });
2693
+ throw new HTTPException(400, {
2694
+ message: "viewer attach requires a live plane",
2695
+ });
2352
2696
  }
2353
2697
  if (wantDesktop) requirePermission(grant, "stream:view");
2354
2698
  if (wantTerminal) requirePermission(grant, "terminal:attach");
@@ -2494,7 +2838,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2494
2838
  terminalUrl: terminal?.url ?? null,
2495
2839
  terminalToken: terminal?.token ?? null,
2496
2840
  terminalExpiresAt: terminal?.expiresAt ?? null,
2497
- terminalTransport: terminal ? ("pty-ws" as const) : null,
2841
+ terminalTransport: terminal?.transport ?? null,
2498
2842
  } satisfies AttachViewerResponse;
2499
2843
  return c.json(response, 201);
2500
2844
  });
@@ -3093,6 +3437,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3093
3437
  });
3094
3438
  }
3095
3439
 
3440
+ export function goalProposalMatchesExpectedRevision(
3441
+ revision: Pick<SessionGoalRevision, "baseObjectiveRevision">,
3442
+ expectedObjectiveRevision: number,
3443
+ ): boolean {
3444
+ return revision.baseObjectiveRevision === expectedObjectiveRevision;
3445
+ }
3446
+
3096
3447
  function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
3097
3448
  const limit = Number(raw ?? fallback);
3098
3449
  if (!Number.isFinite(limit)) {
@@ -3162,6 +3513,7 @@ export function sessionAuthorizationOperationForHttp(
3162
3513
  return null;
3163
3514
  }
3164
3515
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
3516
+ if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
3165
3517
  if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
3166
3518
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
3167
3519
  return "session.mcp.approval_policy.write";
@@ -3176,6 +3528,9 @@ export function sessionAuthorizationOperationForHttp(
3176
3528
  if (suffix === "/realtime/gateway" && verb === "POST") {
3177
3529
  return "session.realtime.start";
3178
3530
  }
3531
+ if (suffix === "/realtime/supergrok" && verb === "POST") {
3532
+ return "session.realtime.start";
3533
+ }
3179
3534
  if (suffix === "/realtime" && verb === "POST") {
3180
3535
  return "session.realtime.start";
3181
3536
  }
@@ -3198,6 +3553,10 @@ export function sessionAuthorizationOperationForHttp(
3198
3553
  ? "session.goal.write"
3199
3554
  : null;
3200
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
+ }
3201
3560
  if (suffix === "/context/clear" || suffix === "/context/compact") {
3202
3561
  return verb === "POST" ? "session.context.write" : null;
3203
3562
  }
@@ -3388,7 +3747,9 @@ export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): s
3388
3747
 
3389
3748
  function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3390
3749
  if (value.length > 2_048) {
3391
- throw new HTTPException(400, { message: "agent topology cursor is invalid" });
3750
+ throw new HTTPException(400, {
3751
+ message: "agent topology cursor is invalid",
3752
+ });
3392
3753
  }
3393
3754
  try {
3394
3755
  const parsed = z
@@ -3421,7 +3782,9 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3421
3782
  }
3422
3783
  return parsed;
3423
3784
  } catch {
3424
- throw new HTTPException(400, { message: "agent topology cursor is invalid" });
3785
+ throw new HTTPException(400, {
3786
+ message: "agent topology cursor is invalid",
3787
+ });
3425
3788
  }
3426
3789
  }
3427
3790
 
@@ -3434,7 +3797,9 @@ export function agentTopologyQuery(query: Record<string, string>): {
3434
3797
  const rawLimit = query.limit;
3435
3798
  const limit = rawLimit === undefined ? 25 : Number(rawLimit);
3436
3799
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
3437
- throw new HTTPException(400, { message: "limit must be an integer between 1 and 100" });
3800
+ throw new HTTPException(400, {
3801
+ message: "limit must be an integer between 1 and 100",
3802
+ });
3438
3803
  }
3439
3804
  const rawParent = query.parentSessionId;
3440
3805
  if (
@@ -3449,17 +3814,23 @@ export function agentTopologyQuery(query: Record<string, string>): {
3449
3814
  const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
3450
3815
  const search = query.search?.trim();
3451
3816
  if (search && search.length > 200) {
3452
- throw new HTTPException(400, { message: "search must be at most 200 characters" });
3817
+ throw new HTTPException(400, {
3818
+ message: "search must be at most 200 characters",
3819
+ });
3453
3820
  }
3454
3821
  if (search && rawParent !== undefined) {
3455
- throw new HTTPException(400, { message: "search cannot be combined with parentSessionId" });
3822
+ throw new HTTPException(400, {
3823
+ message: "search cannot be combined with parentSessionId",
3824
+ });
3456
3825
  }
3457
3826
  const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
3458
3827
  if (
3459
3828
  envelope &&
3460
3829
  (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
3461
3830
  ) {
3462
- throw new HTTPException(400, { message: "agent topology cursor does not match its filters" });
3831
+ throw new HTTPException(400, {
3832
+ message: "agent topology cursor does not match its filters",
3833
+ });
3463
3834
  }
3464
3835
  return {
3465
3836
  limit,
@@ -3494,6 +3865,11 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
3494
3865
 
3495
3866
  /** Stable, value-free JSON errors for only the create-session boundary. */
3496
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
+ }
3497
3873
  if (error instanceof SessionSpawnDeniedError) {
3498
3874
  return c.json(
3499
3875
  sessionSpawnDenialEnvelope(error),