@opengeni/api-router 0.5.7 → 0.9.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.
package/src/mcp/server.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
+ defaultRepositoryMountPath,
4
+ SESSION_EVENT_RAW_DELTA_TYPES,
5
+ SessionEventLatestClass,
6
+ SessionEventPayloadMode,
7
+ SessionEventReadDirection,
8
+ SessionEventReadMode,
9
+ SessionEventResultMode,
10
+ SessionEventSemanticClass,
11
+ SessionEventType,
12
+ compactSessionEventResult,
13
+ sessionEventLatestClassToSemanticClass,
3
14
  SessionMcpCredentialUpdateInput,
4
15
  VariableSetVariableName,
5
16
  type AccessGrant,
6
17
  type GitHubRepository,
7
18
  type Permission,
8
19
  type ResourceRef,
20
+ type SessionAuthorizationOperation,
21
+ type Session,
9
22
  UpdateScheduledTaskRequest,
10
23
  } from "@opengeni/contracts";
11
24
  import {
@@ -21,12 +34,18 @@ import {
21
34
  getSessionTurn,
22
35
  getVariableSet,
23
36
  getVariableSetByName,
24
- listGitHubInstallationIdsForWorkspace,
37
+ areGitHubRepositoriesAllowedForWorkspace,
25
38
  listScheduledTaskRuns,
26
39
  listScheduledTasks,
27
- listSessionEvents,
28
- listSessions,
40
+ listSessionEventPage,
41
+ listSessionDiscoverySummaries,
42
+ projectEffectiveControlForRelatedAccess,
43
+ projectSessionForRelatedAccess,
44
+ type SessionDiscoveryCursor,
45
+ type SessionDiscoveryOrderBy,
29
46
  listRigs,
47
+ listRigChangeMonitoringSummaries,
48
+ listRigVersionMonitoringSummaries,
30
49
  listSocialConnections,
31
50
  listSocialPosts,
32
51
  listVariableSets,
@@ -38,6 +57,7 @@ import {
38
57
  requireSession,
39
58
  saveWorkspaceMemory,
40
59
  searchWorkspaceMemories,
60
+ serializeEffectiveSessionControl,
41
61
  setSessionGoalStatus,
42
62
  setVariableSetVariable,
43
63
  updateScheduledTask,
@@ -49,20 +69,21 @@ import {
49
69
  import { appendAndPublishEvents } from "@opengeni/events";
50
70
  import {
51
71
  createGitHubAppInstallationToken,
52
- createSignedState,
53
72
  GitHubAppConfigurationError,
54
73
  githubAppMissingSettings,
55
- listGitHubAppRepositories,
56
- stateMaxAgeSeconds,
57
74
  } from "@opengeni/github";
58
75
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
59
76
  import * as z4 from "zod/v4";
60
- import { hasPermission } from "@opengeni/core";
77
+ import {
78
+ hasPermission,
79
+ requireSessionAuthorization,
80
+ requireSessionAuthorizationListScope,
81
+ type ResolvedSessionAuthorization,
82
+ } from "@opengeni/core";
61
83
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
62
84
  import type { ApiRouteDeps } from "@opengeni/core";
85
+ import { listWorkspaceGitHubRepositories } from "../github-access";
63
86
  import {
64
- listRigChangesForApi,
65
- listRigVersionsForApi,
66
87
  promoteVerifiedDefinitionEditChangeForApi,
67
88
  proposeRigChangeForApi,
68
89
  requireRigChangeForApi,
@@ -91,6 +112,9 @@ import {
91
112
  sendAgentSessionMessage,
92
113
  steerAgentSession,
93
114
  updateSessionTitle,
115
+ sessionWithEffectiveToolPolicy,
116
+ workspaceSessionToolPolicyDefaultServerIds,
117
+ workspaceSessionToolPolicyServerIds,
94
118
  type AgentSessionCommandContext,
95
119
  } from "@opengeni/core";
96
120
  import {
@@ -103,13 +127,19 @@ import {
103
127
  type FleetServices,
104
128
  type RunOnOp,
105
129
  } from "@opengeni/core";
106
- import { capEventPage, capSessionDetail } from "./session-view";
130
+ import {
131
+ boundSessionEventCompactResult,
132
+ boundSessionEventMcpPage,
133
+ boundSessionDetailMcp,
134
+ boundRigDetailMcp,
135
+ SESSION_EVENT_MCP_MAX_BYTES,
136
+ } from "./session-view";
107
137
  import type { ToolspaceMcpSurface } from "./toolspace";
108
138
 
109
139
  export type McpServerOptions = {
110
- // Origin of the HTTP request that reached the MCP route; last-resort base
111
- // for links the server mints (github_connect_link) when neither
112
- // OPENGENI_PUBLIC_BASE_URL nor the manifest base URL is configured.
140
+ // Origin of the HTTP request that reached the MCP route. Retained in the
141
+ // options ABI for browser-oriented tools; github_connect_link does not use
142
+ // it or mint state while new installation binding is disabled.
113
143
  requestOrigin?: string | null;
114
144
  toolspace?: ToolspaceMcpSurface | null;
115
145
  workspaceMemoryEnabled?: boolean | undefined;
@@ -148,8 +178,13 @@ export function buildOpenGeniMcpServer(
148
178
  inputSchema: { title: z4.string().min(1).max(200) },
149
179
  },
150
180
  async ({ title }) => {
151
- const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
152
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
181
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.title.write");
182
+ const result = await updateSessionTitle(deps, grant, sessionId, title, "agent");
183
+ return json({
184
+ ok: true,
185
+ updated: result.updated,
186
+ title: result.title ?? title,
187
+ });
153
188
  },
154
189
  );
155
190
  }
@@ -178,19 +213,20 @@ export function buildOpenGeniMcpServer(
178
213
  registerRigTools(server, deps, grant, can, sessionId, json);
179
214
  }
180
215
 
181
- // Orchestration, variableSet, and GitHub-connect tools are permission-gated
216
+ // Orchestration, variableSet, and GitHub status/token tools are permission-gated
182
217
  // at registration: a grant without the permission does not see the tool.
183
218
  // Sandboxed workers reach this server with the first-party delegated
184
219
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
185
220
  // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
186
- // so agents can spawn/read sessions, manage variable set variables,
187
- // and mint GitHub install links out of the box. A user DEMOTES a specific
221
+ // so agents can spawn/read sessions, manage variable set variables, inspect
222
+ // GitHub connection availability, and refresh scoped tokens for already-bound
223
+ // repositories out of the box. A user DEMOTES a specific
188
224
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
189
225
  // the creator's own grant); operators still cap what any session can be given.
190
226
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
191
227
  registerVariableSetTools(server, deps, grant, can, json);
192
228
  if (can("github:use")) {
193
- registerGitHubConnectTool(server, deps, grant, options, json);
229
+ registerGitHubConnectTool(server, deps, json);
194
230
  // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
195
231
  // worker-signed sessionId claim so it mints for THIS session's repos), gated on
196
232
  // the same github:use capability as github_connect_link.
@@ -214,7 +250,9 @@ export function buildOpenGeniMcpServer(
214
250
  if (file.status !== "ready") {
215
251
  throw new Error(`file is ${file.status}`);
216
252
  }
217
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
253
+ const signed = await deps.objectStorage.createGetUrl({
254
+ key: file.objectKey,
255
+ });
218
256
  return json({
219
257
  file: {
220
258
  id: file.id,
@@ -246,11 +284,7 @@ export function buildOpenGeniMcpServer(
246
284
  },
247
285
  async ({ limit }) => {
248
286
  try {
249
- const installationIds = await listGitHubInstallationIdsForWorkspace(
250
- deps.db,
251
- grant.workspaceId,
252
- );
253
- const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
287
+ const repositories = await listWorkspaceGitHubRepositories(deps, grant.workspaceId);
254
288
  const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
255
289
  return json({
256
290
  repositories: visible.map((repository) =>
@@ -380,7 +414,9 @@ export function buildOpenGeniMcpServer(
380
414
  inputSchema: { limit: z4.number().int().positive().optional() },
381
415
  },
382
416
  async ({ limit }) =>
383
- json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }),
417
+ json({
418
+ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100),
419
+ }),
384
420
  );
385
421
 
386
422
  server.registerTool(
@@ -429,7 +465,11 @@ export function buildOpenGeniMcpServer(
429
465
  payload,
430
466
  toolsProvided: scheduledTaskToolsProvided(args),
431
467
  });
432
- await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
468
+ await syncCreatedScheduledTask({
469
+ db: deps.db,
470
+ workflowClient: deps.workflowClient,
471
+ task,
472
+ });
433
473
  return json(task);
434
474
  },
435
475
  );
@@ -526,7 +566,10 @@ export function buildOpenGeniMcpServer(
526
566
  {
527
567
  description:
528
568
  "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
529
- inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
569
+ inputSchema: {
570
+ id: z4.string().uuid(),
571
+ triggerId: z4.string().min(1).max(128).optional(),
572
+ },
530
573
  },
531
574
  async ({ id, triggerId }) => {
532
575
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
@@ -584,7 +627,10 @@ export function buildOpenGeniMcpServer(
584
627
  "scheduled_task_runs_list",
585
628
  {
586
629
  description: "List runs for a scheduled task.",
587
- inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
630
+ inputSchema: {
631
+ taskId: z4.string().uuid(),
632
+ limit: z4.number().int().positive().optional(),
633
+ },
588
634
  },
589
635
  async ({ taskId, limit }) =>
590
636
  json({
@@ -686,6 +732,7 @@ function registerGoalTools(
686
732
  },
687
733
  },
688
734
  async ({ text, successCriteria, maxAutoContinuations }) => {
735
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
689
736
  await requireSession(deps.db, grant.workspaceId, sessionId);
690
737
  const callerTurnId =
691
738
  typeof grant.metadata?.["turnId"] === "string"
@@ -730,6 +777,7 @@ function registerGoalTools(
730
777
  },
731
778
  },
732
779
  async ({ text, successCriteria, progressNote }) => {
780
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
733
781
  await requireSession(deps.db, grant.workspaceId, sessionId);
734
782
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
735
783
  if (!existing) {
@@ -767,6 +815,7 @@ function registerGoalTools(
767
815
  inputSchema: { evidence: z4.string().min(1) },
768
816
  },
769
817
  async ({ evidence }) => {
818
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
770
819
  await requireSession(deps.db, grant.workspaceId, sessionId);
771
820
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
772
821
  if (!existing) {
@@ -796,6 +845,7 @@ function registerGoalTools(
796
845
  inputSchema: { rationale: z4.string().min(1) },
797
846
  },
798
847
  async ({ rationale }) => {
848
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
799
849
  await requireSession(deps.db, grant.workspaceId, sessionId);
800
850
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
801
851
  if (!existing) {
@@ -826,7 +876,22 @@ function registerGoalTools(
826
876
  );
827
877
  }
828
878
 
829
- type JsonResult = (value: unknown) => { content: Array<{ type: "text"; text: string }> };
879
+ type JsonResult = (value: unknown) => {
880
+ content: Array<{ type: "text"; text: string }>;
881
+ };
882
+
883
+ async function authorizeFirstPartySession(
884
+ deps: ApiRouteDeps,
885
+ grant: AccessGrant,
886
+ sessionId: string,
887
+ operation: SessionAuthorizationOperation,
888
+ ): Promise<ResolvedSessionAuthorization | null> {
889
+ return await requireSessionAuthorization(deps, grant, {
890
+ sessionId,
891
+ operation,
892
+ surface: "first_party_mcp",
893
+ });
894
+ }
830
895
 
831
896
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
832
897
 
@@ -969,7 +1034,11 @@ function registerFleetTools(
969
1034
  sessionId: string,
970
1035
  json: JsonResult,
971
1036
  ): void {
972
- const services: FleetServices = { db: deps.db, settings: deps.settings, bus: deps.bus };
1037
+ const services: FleetServices = {
1038
+ db: deps.db,
1039
+ settings: deps.settings,
1040
+ bus: deps.bus,
1041
+ };
973
1042
 
974
1043
  // Resolve the session's group sandbox (the default/home fleet member) at
975
1044
  // call-time via the shared helper (same context the user-authenticated swap
@@ -1026,7 +1095,11 @@ function registerFleetTools(
1026
1095
  workdir: z4.string().optional(),
1027
1096
  }),
1028
1097
  z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
1029
- z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
1098
+ z4.object({
1099
+ kind: z4.literal("write"),
1100
+ path: z4.string().min(1),
1101
+ content: z4.string(),
1102
+ }),
1030
1103
  ]),
1031
1104
  },
1032
1105
  },
@@ -1046,7 +1119,10 @@ function registerFleetTools(
1046
1119
  },
1047
1120
  async ({ kind, name }) =>
1048
1121
  json(
1049
- await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) }),
1122
+ await provisionSandbox(services, await fleetContext(), {
1123
+ kind,
1124
+ ...(name ? { name } : {}),
1125
+ }),
1050
1126
  ),
1051
1127
  );
1052
1128
  }
@@ -1100,24 +1176,31 @@ function registerRigTools(
1100
1176
  server.registerTool(
1101
1177
  "rig_get",
1102
1178
  {
1103
- description: "Get a rig, its versions, and recent changes.",
1179
+ description:
1180
+ "Get one rig's bounded active definition plus compact historical version/change summaries. Historical setup scripts, checks, payloads, and verification logs are represented by counts/byte facts rather than copied into model context; use the access-controlled REST detail endpoints for exact retained definitions.",
1104
1181
  inputSchema: {
1105
1182
  rigId: z4.string().uuid(),
1183
+ versionLimit: z4.number().int().positive().optional(),
1106
1184
  changeLimit: z4.number().int().positive().optional(),
1107
1185
  },
1108
1186
  },
1109
- async ({ rigId, changeLimit }) => {
1187
+ async ({ rigId, versionLimit, changeLimit }) => {
1110
1188
  const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1111
- return json({
1112
- rig,
1113
- versions: await listRigVersionsForApi({ db: deps.db }, grant.workspaceId, rig.id),
1114
- changes: await listRigChangesForApi(
1115
- { db: deps.db },
1189
+ const [versions, changes] = await Promise.all([
1190
+ listRigVersionMonitoringSummaries(
1191
+ deps.db,
1116
1192
  grant.workspaceId,
1117
1193
  rig.id,
1118
- boundedMcpLimit(changeLimit),
1194
+ boundedRigHistoryLimit(versionLimit),
1119
1195
  ),
1120
- });
1196
+ listRigChangeMonitoringSummaries(
1197
+ deps.db,
1198
+ grant.workspaceId,
1199
+ rig.id,
1200
+ boundedRigHistoryLimit(changeLimit),
1201
+ ),
1202
+ ]);
1203
+ return json(boundRigDetailMcp(rig, versions, changes));
1121
1204
  },
1122
1205
  );
1123
1206
 
@@ -1237,6 +1320,7 @@ function exactAgentCommandContext(
1237
1320
  return {
1238
1321
  accountId: grant.accountId,
1239
1322
  workspaceId: grant.workspaceId,
1323
+ subjectId: grant.subjectId,
1240
1324
  callerSessionId,
1241
1325
  callerTurnId: turnId,
1242
1326
  callerAttemptId: attemptId,
@@ -1257,11 +1341,47 @@ function registerWorkspaceOrchestrationTools(
1257
1341
  server.registerTool(
1258
1342
  "sessions_list",
1259
1343
  {
1260
- description: "List sessions in this workspace, newest first.",
1261
- inputSchema: { limit: z4.number().int().positive().optional() },
1344
+ description: `List compact high-level session status in this workspace. Defaults to creation order; use orderBy=updatedAt with decimal activity-revision updatedAfter/updatedThrough tokens for gap-free indexed incremental monitoring independent of application clocks. Cursors are opaque revision-fenced keysets. includeLastMessage is opt-in; its rendered previews share a deterministic ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 aggregate budget, and omitted previews include a bounded session_events drill-down input (exact message type, direction=before, limit=1, monitoring summary). Use session_get for exact known targets and detailed resources/tools/settings. The list never returns full session objects or history.`,
1345
+ inputSchema: {
1346
+ limit: z4.number().int().positive().max(100).optional(),
1347
+ cursor: z4.string().max(512).optional(),
1348
+ includeLastMessage: z4.boolean().optional(),
1349
+ orderBy: z4.enum(["createdAt", "updatedAt"]).optional(),
1350
+ updatedAfter: z4.string().max(64).optional(),
1351
+ },
1352
+ },
1353
+ async ({ limit, cursor, includeLastMessage, orderBy: requestedOrderBy, updatedAfter }) => {
1354
+ const authorizationScope = await requireSessionAuthorizationListScope(
1355
+ deps,
1356
+ grant,
1357
+ "first_party_mcp",
1358
+ );
1359
+ const decodedCursor = cursor ? decodeSessionDiscoveryCursor(cursor) : undefined;
1360
+ const orderBy: SessionDiscoveryOrderBy =
1361
+ requestedOrderBy ?? decodedCursor?.orderBy ?? "createdAt";
1362
+ if (decodedCursor && decodedCursor.orderBy !== orderBy) {
1363
+ throw new Error("sessions_list cursor order does not match orderBy");
1364
+ }
1365
+ const normalizedUpdatedAfter =
1366
+ updatedAfter !== undefined
1367
+ ? normalizeSessionDiscoveryRevision(updatedAfter, "updatedAfter")
1368
+ : (decodedCursor?.updatedAfter ?? undefined);
1369
+ if (normalizedUpdatedAfter !== undefined && orderBy !== "updatedAt") {
1370
+ throw new Error("sessions_list updatedAfter requires orderBy=updatedAt");
1371
+ }
1372
+ if (decodedCursor && decodedCursor.updatedAfter !== (normalizedUpdatedAfter ?? null)) {
1373
+ throw new Error("sessions_list cursor does not match updatedAfter");
1374
+ }
1375
+ const page = await listSessionDiscoverySummaries(deps.db, grant.workspaceId, {
1376
+ limit: boundedSessionDiscoveryLimit(limit),
1377
+ ...(decodedCursor ? { cursor: decodedCursor } : {}),
1378
+ includeLastMessage: includeLastMessage === true,
1379
+ orderBy,
1380
+ ...(normalizedUpdatedAfter ? { updatedAfter: normalizedUpdatedAfter } : {}),
1381
+ ...(authorizationScope ? { authorizationScope } : {}),
1382
+ });
1383
+ return json(capSessionDiscoveryPage(page, includeLastMessage === true));
1262
1384
  },
1263
- async ({ limit }) =>
1264
- json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }),
1265
1385
  );
1266
1386
 
1267
1387
  server.registerTool(
@@ -1272,15 +1392,32 @@ function registerWorkspaceOrchestrationTools(
1272
1392
  inputSchema: { sessionId: z4.string().uuid() },
1273
1393
  },
1274
1394
  async ({ sessionId }) => {
1395
+ const authorization = await authorizeFirstPartySession(
1396
+ deps,
1397
+ grant,
1398
+ sessionId,
1399
+ "session.read",
1400
+ );
1275
1401
  const session = await getSession(deps.db, grant.workspaceId, sessionId);
1276
1402
  if (!session) {
1277
1403
  throw new Error("session not found");
1278
1404
  }
1279
1405
  const queue = await getSessionQueueSnapshot(deps.db, grant.workspaceId, sessionId);
1280
- return json({
1281
- ...capSessionDetail(session),
1282
- effectiveControl: queue?.effectiveControl ?? null,
1283
- });
1406
+ const projected = projectSessionForRelatedAccess(
1407
+ {
1408
+ ...session,
1409
+ effectiveControl: queue?.effectiveControl ?? session.effectiveControl,
1410
+ },
1411
+ authorization?.relatedSessionAccess ?? "root",
1412
+ );
1413
+ return json(
1414
+ boundSessionDetailMcp(
1415
+ await withMcpEffectivePolicy(deps, grant.workspaceId, {
1416
+ ...projected,
1417
+ effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
1418
+ }),
1419
+ ),
1420
+ );
1284
1421
  },
1285
1422
  );
1286
1423
 
@@ -1288,28 +1425,108 @@ function registerWorkspaceOrchestrationTools(
1288
1425
  "session_events",
1289
1426
  {
1290
1427
  description:
1291
- "Read a session's event timeline (oldest first), to monitor another session's progress. Pass `after` = the highest event `sequence` already seen to page forward; the response's `nextAfter` is that cursor. The response is BYTE-CAPPED for a monitoring glance: fat per-event payloads (a worker's verbatim tool outputs, message/reasoning bodies) are clamped, and an over-budget page is reduced to its head + tail with a marker page the gap with `after`/`limit`, or read the worker's session notebook, if you need omitted content verbatim. `nextAfter` always advances past every event the page covered, so paging never skips real events.",
1428
+ "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the authoritative newest durable sequence in exactly one semantic class; `receipt` is the concise alias for `tool_receipt`, and latest cannot be combined with type or class filters. Add `resultMode=compact` to latest for one bounded result-bearing completion/checkpoint/receipt without another inference. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1292
1429
  inputSchema: {
1293
1430
  sessionId: z4.string().uuid(),
1294
1431
  after: z4.number().int().nonnegative().optional(),
1432
+ before: z4.number().int().positive().optional(),
1295
1433
  limit: z4.number().int().positive().optional(),
1434
+ direction: z4.enum(SessionEventReadDirection.options).optional(),
1435
+ mode: z4.enum(SessionEventReadMode.options).optional(),
1436
+ payloadMode: z4.enum(SessionEventPayloadMode.options).optional(),
1437
+ resultMode: z4.enum(SessionEventResultMode.options).optional(),
1438
+ includeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1439
+ excludeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1440
+ includeClasses: z4
1441
+ .array(z4.enum(SessionEventSemanticClass.options))
1442
+ .max(SessionEventSemanticClass.options.length)
1443
+ .optional(),
1444
+ excludeClasses: z4
1445
+ .array(z4.enum(SessionEventSemanticClass.options))
1446
+ .max(SessionEventSemanticClass.options.length)
1447
+ .optional(),
1448
+ latest: z4.enum(SessionEventLatestClass.options).optional(),
1296
1449
  },
1297
1450
  },
1298
- async ({ sessionId, after, limit }) => {
1451
+ async ({
1452
+ sessionId,
1453
+ after,
1454
+ before,
1455
+ limit,
1456
+ direction: requestedDirection,
1457
+ mode: requestedMode,
1458
+ payloadMode: requestedPayloadMode,
1459
+ resultMode: requestedResultMode,
1460
+ includeTypes,
1461
+ excludeTypes,
1462
+ includeClasses,
1463
+ excludeClasses,
1464
+ latest,
1465
+ }) => {
1466
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
1467
+ const latestClass =
1468
+ latest === undefined ? undefined : sessionEventLatestClassToSemanticClass(latest);
1469
+ if (requestedResultMode === "compact" && latestClass === undefined) {
1470
+ throw new Error("resultMode=compact requires latest");
1471
+ }
1299
1472
  await requireSession(deps.db, grant.workspaceId, sessionId);
1300
- const events = await listSessionEvents(
1301
- deps.db,
1302
- grant.workspaceId,
1303
- sessionId,
1304
- after ?? 0,
1305
- boundedMcpLimit(limit),
1306
- );
1307
- const capped = capEventPage(events);
1308
- return json({
1309
- events: capped.events,
1310
- nextAfter: capped.nextAfter ?? after ?? 0,
1311
- ...(capped.truncated ? { truncated: true } : {}),
1473
+ if (
1474
+ latest &&
1475
+ [includeTypes, excludeTypes, includeClasses, excludeClasses].some(
1476
+ (filter) => filter !== undefined,
1477
+ )
1478
+ ) {
1479
+ throw new Error("latest cannot be combined with event filters");
1480
+ }
1481
+ const mode = requestedMode ?? (after !== undefined ? "forensic" : "monitoring");
1482
+ const direction = latestClass
1483
+ ? "before"
1484
+ : (requestedDirection ??
1485
+ (before !== undefined ? "before" : after !== undefined ? "after" : "before"));
1486
+ const payloadMode =
1487
+ requestedResultMode === "compact"
1488
+ ? "full"
1489
+ : (requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full"));
1490
+ const dbPage = await listSessionEventPage(deps.db, grant.workspaceId, sessionId, {
1491
+ after: after ?? 0,
1492
+ ...(before !== undefined ? { before } : {}),
1493
+ direction,
1494
+ limit: latestClass ? 1 : boundedSessionEventMcpLimit(limit),
1495
+ payloadMode,
1496
+ includeTypes: includeTypes ?? [],
1497
+ excludeTypes: excludeTypes ?? [],
1498
+ includeClasses: latestClass ? [latestClass] : (includeClasses ?? []),
1499
+ excludeClasses: excludeClasses ?? [],
1500
+ ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
1501
+ ...(latestClass ? { authoritativeLatest: true } : {}),
1502
+ maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
1312
1503
  });
1504
+ if (requestedResultMode === "compact") {
1505
+ const event = dbPage.events[0];
1506
+ return json(
1507
+ event
1508
+ ? boundSessionEventCompactResult(
1509
+ compactSessionEventResult(
1510
+ event,
1511
+ latestClass!,
1512
+ dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
1513
+ ),
1514
+ )
1515
+ : null,
1516
+ );
1517
+ }
1518
+ return json(
1519
+ boundSessionEventMcpPage({
1520
+ events: dbPage.events,
1521
+ mode,
1522
+ payloadMode,
1523
+ direction,
1524
+ sourceHasMore: dbPage.hasMore,
1525
+ sourceTruncatedBy: dbPage.truncatedBy,
1526
+ after: after ?? 0,
1527
+ before: before ?? null,
1528
+ }),
1529
+ );
1313
1530
  },
1314
1531
  );
1315
1532
  }
@@ -1360,7 +1577,14 @@ function registerWorkspaceOrchestrationTools(
1360
1577
  idempotencyKey: z4.string().min(1).max(200).optional(),
1361
1578
  // First-party MCP token permissions for the spawned session; every
1362
1579
  // permission must be held by this grant (validated in the domain).
1363
- firstPartyMcpPermissions: z4.array(z4.string()).optional(),
1580
+ // A goal requires goals:manage in the resulting set; it is never
1581
+ // silently added beyond the inherited or explicit authority.
1582
+ firstPartyMcpPermissions: z4
1583
+ .array(z4.string())
1584
+ .optional()
1585
+ .describe(
1586
+ "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
1587
+ ),
1364
1588
  // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
1365
1589
  // creator's box — one filesystem/repo/desktop, N independent conversations;
1366
1590
  // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
@@ -1394,7 +1618,13 @@ function registerWorkspaceOrchestrationTools(
1394
1618
  // arbitrary session's wake channel without sessions:control on it.
1395
1619
  },
1396
1620
  },
1397
- async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)),
1621
+ async (args) => {
1622
+ if (callerSessionId !== null) {
1623
+ await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1624
+ }
1625
+ const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
1626
+ return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
1627
+ },
1398
1628
  );
1399
1629
  }
1400
1630
 
@@ -1414,6 +1644,7 @@ function registerWorkspaceOrchestrationTools(
1414
1644
  },
1415
1645
  },
1416
1646
  async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
1647
+ await authorizeFirstPartySession(deps, grant, targetSessionId, "session.append");
1417
1648
  if (callerSessionId !== null) {
1418
1649
  if ((mcpCredentialUpdates?.length ?? 0) > 0) {
1419
1650
  throw new Error("internal session updates cannot change MCP credentials");
@@ -1464,6 +1695,12 @@ function registerWorkspaceOrchestrationTools(
1464
1695
  },
1465
1696
  },
1466
1697
  async ({ sessionId, idempotencyKey, reason }) => {
1698
+ const authorization = await authorizeFirstPartySession(
1699
+ deps,
1700
+ grant,
1701
+ sessionId,
1702
+ "session.control",
1703
+ );
1467
1704
  if (callerSessionId !== null) {
1468
1705
  const controlled = await controlAgentSessionWorkstream(
1469
1706
  deps,
@@ -1477,27 +1714,37 @@ function registerWorkspaceOrchestrationTools(
1477
1714
  );
1478
1715
  return json({
1479
1716
  receiptId: controlled.receipt.id,
1480
- effectiveControl: controlled.control,
1717
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1718
+ serializeEffectiveSessionControl(controlled.control),
1719
+ sessionId,
1720
+ authorization?.relatedSessionAccess ?? "root",
1721
+ ),
1481
1722
  interruptionCount: controlled.interruptionCount,
1482
1723
  replay: controlled.replay,
1483
1724
  });
1484
1725
  }
1485
- return json(
1486
- await controlHumanSessionWorkstream(
1487
- deps,
1488
- {
1489
- accountId: grant.accountId,
1490
- workspaceId: grant.workspaceId,
1491
- sessionId,
1492
- subjectId: grant.subjectId,
1493
- },
1494
- {
1495
- action: "pause",
1496
- clientEventId: idempotencyKey,
1497
- ...(reason ? { reason } : {}),
1498
- },
1499
- ),
1726
+ const controlled = await controlHumanSessionWorkstream(
1727
+ deps,
1728
+ {
1729
+ accountId: grant.accountId,
1730
+ workspaceId: grant.workspaceId,
1731
+ sessionId,
1732
+ subjectId: grant.subjectId,
1733
+ },
1734
+ {
1735
+ action: "pause",
1736
+ clientEventId: idempotencyKey,
1737
+ ...(reason ? { reason } : {}),
1738
+ },
1500
1739
  );
1740
+ return json({
1741
+ ...controlled,
1742
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1743
+ controlled.effectiveControl,
1744
+ sessionId,
1745
+ authorization?.relatedSessionAccess ?? "root",
1746
+ ),
1747
+ });
1501
1748
  },
1502
1749
  );
1503
1750
 
@@ -1513,6 +1760,12 @@ function registerWorkspaceOrchestrationTools(
1513
1760
  },
1514
1761
  },
1515
1762
  async ({ sessionId, idempotencyKey, reason }) => {
1763
+ const authorization = await authorizeFirstPartySession(
1764
+ deps,
1765
+ grant,
1766
+ sessionId,
1767
+ "session.control",
1768
+ );
1516
1769
  if (callerSessionId !== null) {
1517
1770
  const controlled = await controlAgentSessionWorkstream(
1518
1771
  deps,
@@ -1526,27 +1779,37 @@ function registerWorkspaceOrchestrationTools(
1526
1779
  );
1527
1780
  return json({
1528
1781
  receiptId: controlled.receipt.id,
1529
- effectiveControl: controlled.control,
1782
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1783
+ serializeEffectiveSessionControl(controlled.control),
1784
+ sessionId,
1785
+ authorization?.relatedSessionAccess ?? "root",
1786
+ ),
1530
1787
  interruptionCount: controlled.interruptionCount,
1531
1788
  replay: controlled.replay,
1532
1789
  });
1533
1790
  }
1534
- return json(
1535
- await controlHumanSessionWorkstream(
1536
- deps,
1537
- {
1538
- accountId: grant.accountId,
1539
- workspaceId: grant.workspaceId,
1540
- sessionId,
1541
- subjectId: grant.subjectId,
1542
- },
1543
- {
1544
- action: "resume",
1545
- clientEventId: idempotencyKey,
1546
- ...(reason ? { reason } : {}),
1547
- },
1548
- ),
1791
+ const controlled = await controlHumanSessionWorkstream(
1792
+ deps,
1793
+ {
1794
+ accountId: grant.accountId,
1795
+ workspaceId: grant.workspaceId,
1796
+ sessionId,
1797
+ subjectId: grant.subjectId,
1798
+ },
1799
+ {
1800
+ action: "resume",
1801
+ clientEventId: idempotencyKey,
1802
+ ...(reason ? { reason } : {}),
1803
+ },
1549
1804
  );
1805
+ return json({
1806
+ ...controlled,
1807
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1808
+ controlled.effectiveControl,
1809
+ sessionId,
1810
+ authorization?.relatedSessionAccess ?? "root",
1811
+ ),
1812
+ });
1550
1813
  },
1551
1814
  );
1552
1815
 
@@ -1563,6 +1826,7 @@ function registerWorkspaceOrchestrationTools(
1563
1826
  },
1564
1827
  },
1565
1828
  async ({ sessionId, instruction, idempotencyKey }) => {
1829
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.steer");
1566
1830
  const result = await steerAgentSession(
1567
1831
  deps,
1568
1832
  exactAgentCommandContext(grant, callerSessionId),
@@ -1590,15 +1854,14 @@ function registerWorkspaceOrchestrationTools(
1590
1854
  },
1591
1855
  },
1592
1856
  async ({ session_id, title }) => {
1857
+ await authorizeFirstPartySession(deps, grant, session_id, "session.title.write");
1593
1858
  await requireSession(deps.db, grant.workspaceId, session_id);
1594
- const result = await updateSessionTitle(
1595
- deps,
1596
- grant.workspaceId,
1597
- session_id,
1598
- title,
1599
- "agent",
1600
- );
1601
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
1859
+ const result = await updateSessionTitle(deps, grant, session_id, title, "agent");
1860
+ return json({
1861
+ ok: true,
1862
+ updated: result.updated,
1863
+ title: result.title ?? title,
1864
+ });
1602
1865
  },
1603
1866
  );
1604
1867
  }
@@ -1703,7 +1966,11 @@ function registerVariableSetTools(
1703
1966
  variableSetId: variableSet.id,
1704
1967
  variableName: parsedName.data,
1705
1968
  });
1706
- const responseVariableSet = { id: variableSet.id, name: variableSet.name, created };
1969
+ const responseVariableSet = {
1970
+ id: variableSet.id,
1971
+ name: variableSet.name,
1972
+ created,
1973
+ };
1707
1974
  return json({
1708
1975
  variableSet: responseVariableSet,
1709
1976
  environment: responseVariableSet,
@@ -1750,22 +2017,15 @@ function registerVariableSetTools(
1750
2017
  }
1751
2018
  }
1752
2019
 
1753
- // GitHub connect link for manager-style agents: mirrors GET .../github/app but
1754
- // returns a browser entry URL (.../github/connect) that plants the CSRF state
1755
- // cookie before forwarding to GitHub, because an MCP-issued link is opened in
1756
- // a browser that never called the API directly.
1757
- function registerGitHubConnectTool(
1758
- server: McpServer,
1759
- deps: ApiRouteDeps,
1760
- grant: AccessGrant,
1761
- options: McpServerOptions,
1762
- json: JsonResult,
1763
- ): void {
2020
+ // The tool remains registered for compatibility, but every new installation
2021
+ // binding entry point is fail-closed until a provider-supported authority
2022
+ // proof stronger than installation visibility is available.
2023
+ function registerGitHubConnectTool(server: McpServer, deps: ApiRouteDeps, json: JsonResult): void {
1764
2024
  server.registerTool(
1765
2025
  "github_connect_link",
1766
2026
  {
1767
2027
  description:
1768
- "Create a workspace-bound GitHub App install link to share with a human. Opening it redirects to GitHub to install the app and select repositories for this workspace; completing the connection requires the person to be signed in to this OpenGeni deployment with github:manage. The link expires.",
2028
+ "Report GitHub App connection availability. New installation binding is disabled until GitHub installation authority can be proven, so installUrl and linkUrl are null.",
1769
2029
  inputSchema: {},
1770
2030
  },
1771
2031
  async () => {
@@ -1773,28 +2033,19 @@ function registerGitHubConnectTool(
1773
2033
  const missing = githubAppMissingSettings(settings);
1774
2034
  const slug = settings.githubAppSlug?.trim() || null;
1775
2035
  if (missing.length > 0 || !slug) {
1776
- return json({ configured: false, appSlug: slug, installUrl: null, missing });
1777
- }
1778
- const base = (
1779
- settings.publicBaseUrl ??
1780
- settings.githubAppManifestBaseUrl ??
1781
- options.requestOrigin ??
1782
- ""
1783
- ).replace(/\/+$/, "");
1784
- if (!base) {
1785
- throw new Error(
1786
- "github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment",
1787
- );
2036
+ return json({
2037
+ configured: false,
2038
+ appSlug: slug,
2039
+ installUrl: null,
2040
+ linkUrl: null,
2041
+ missing,
2042
+ });
1788
2043
  }
1789
- const state = createSignedState(deps.githubStateSecret, {
1790
- accountId: grant.accountId,
1791
- workspaceId: grant.workspaceId,
1792
- });
1793
2044
  return json({
1794
2045
  configured: true,
1795
2046
  appSlug: slug,
1796
- installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
1797
- expiresInSeconds: stateMaxAgeSeconds,
2047
+ installUrl: null,
2048
+ linkUrl: null,
1798
2049
  missing: [],
1799
2050
  });
1800
2051
  },
@@ -1845,9 +2096,20 @@ function registerGitHubTokenTool(
1845
2096
  if (selected.some((item) => item.installationId !== installationId)) {
1846
2097
  throw new Error("GitHub App repository resources must belong to one installation");
1847
2098
  }
2099
+ const repositoryIds = selected.map((item) => item.repositoryId);
2100
+ if (
2101
+ !(await areGitHubRepositoriesAllowedForWorkspace(
2102
+ deps.db,
2103
+ grant.workspaceId,
2104
+ installationId,
2105
+ repositoryIds,
2106
+ ))
2107
+ ) {
2108
+ throw new Error("this workspace no longer authorizes the session's GitHub repositories");
2109
+ }
1848
2110
  const token = await createGitHubAppInstallationToken(deps.settings, {
1849
2111
  installationId,
1850
- repositoryIds: selected.map((item) => item.repositoryId),
2112
+ repositoryIds,
1851
2113
  });
1852
2114
  return json({
1853
2115
  token,
@@ -1881,9 +2143,12 @@ function repositoryWithScheduledTaskResource(
1881
2143
  kind: "repository",
1882
2144
  uri,
1883
2145
  ref: repository.defaultBranch,
1884
- mountPath: repositoryMountPath(uri),
2146
+ mountPath: defaultRepositoryMountPath(uri),
1885
2147
  ...(repository.private
1886
- ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id }
2148
+ ? {
2149
+ githubInstallationId: repository.installationId,
2150
+ githubRepositoryId: repository.id,
2151
+ }
1887
2152
  : {}),
1888
2153
  },
1889
2154
  };
@@ -1892,12 +2157,7 @@ function repositoryWithScheduledTaskResource(
1892
2157
  function normalizedRepositoryUri(value: string): string {
1893
2158
  const url = new URL(value);
1894
2159
  const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
1895
- return `https://${url.hostname.toLowerCase()}/${path}.git`;
1896
- }
1897
-
1898
- function repositoryMountPath(uri: string): string {
1899
- const url = new URL(uri);
1900
- return `repos/${url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`;
2160
+ return `https://${url.host.toLowerCase()}/${path}.git`;
1901
2161
  }
1902
2162
 
1903
2163
  function boundedMcpLimit(limit: number | undefined): number {
@@ -1907,6 +2167,416 @@ function boundedMcpLimit(limit: number | undefined): number {
1907
2167
  return Math.min(500, Math.max(1, Math.floor(limit)));
1908
2168
  }
1909
2169
 
2170
+ function boundedRigHistoryLimit(limit: number | undefined): number {
2171
+ if (!limit || !Number.isFinite(limit)) return 20;
2172
+ return Math.min(100, Math.max(1, Math.floor(limit)));
2173
+ }
2174
+
2175
+ function boundedSessionEventMcpLimit(limit: number | undefined): number {
2176
+ if (!limit || !Number.isFinite(limit)) return 40;
2177
+ return Math.min(250, Math.max(1, Math.floor(limit)));
2178
+ }
2179
+
2180
+ const SESSION_DISCOVERY_DEFAULT_LIMIT = 20;
2181
+ const SESSION_DISCOVERY_MAX_LIMIT = 100;
2182
+ const SESSION_DISCOVERY_TEXT_CHARS = 600;
2183
+ const SESSION_DISCOVERY_PREVIEW_MAX_BYTES = 16_384;
2184
+ const SESSION_DISCOVERY_PREVIEW_OMISSION_REASON = "aggregatePreviewBudget" as const;
2185
+ const SESSION_DISCOVERY_PAGE_MAX_BYTES = 128_000;
2186
+ const SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL = "session_events" as const;
2187
+ const SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT = {
2188
+ direction: "before",
2189
+ limit: 1,
2190
+ mode: "monitoring",
2191
+ payloadMode: "summary",
2192
+ } as const;
2193
+
2194
+ function sessionDiscoveryPreviewDrillDownInput(sessionId: string, type: SessionEventType) {
2195
+ return {
2196
+ sessionId,
2197
+ includeTypes: [type],
2198
+ ...SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT,
2199
+ };
2200
+ }
2201
+
2202
+ function boundedSessionDiscoveryLimit(limit: number | undefined): number {
2203
+ if (!limit || !Number.isFinite(limit)) return SESSION_DISCOVERY_DEFAULT_LIMIT;
2204
+ return Math.min(SESSION_DISCOVERY_MAX_LIMIT, Math.max(1, Math.floor(limit)));
2205
+ }
2206
+
2207
+ export function encodeSessionDiscoveryCursor(cursor: SessionDiscoveryCursor): string {
2208
+ return Buffer.from(
2209
+ JSON.stringify({
2210
+ v: 2,
2211
+ orderBy: cursor.orderBy,
2212
+ sortRevision: cursor.sortRevision,
2213
+ sortAt: cursor.sortAt,
2214
+ id: cursor.id,
2215
+ snapshotAt: cursor.snapshotAt,
2216
+ snapshotRevision: cursor.snapshotRevision,
2217
+ updatedAfter: cursor.updatedAfter,
2218
+ }),
2219
+ "utf8",
2220
+ ).toString("base64url");
2221
+ }
2222
+
2223
+ const SESSION_DISCOVERY_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
2224
+ const SESSION_DISCOVERY_REVISION = /^(?:0|[1-9]\d*)$/;
2225
+ const SESSION_DISCOVERY_REVISION_MAX = 9_223_372_036_854_775_807n;
2226
+ const SESSION_DISCOVERY_UUID =
2227
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2228
+
2229
+ function normalizeSessionDiscoveryTimestamp(value: string, label: string): string {
2230
+ if (!SESSION_DISCOVERY_TIMESTAMP.test(value) || Number.isNaN(new Date(value).getTime())) {
2231
+ throw new Error(`sessions_list ${label} must be an ISO UTC date-time`);
2232
+ }
2233
+ // Preserve up to six fractional digits. Converting through JS Date would
2234
+ // discard PostgreSQL microseconds and can skip equal-millisecond rows.
2235
+ return value;
2236
+ }
2237
+
2238
+ function normalizeSessionDiscoveryRevision(value: string, label: string): string {
2239
+ if (!SESSION_DISCOVERY_REVISION.test(value)) {
2240
+ throw new Error(`sessions_list ${label} must be a decimal activity revision`);
2241
+ }
2242
+ const revision = BigInt(value);
2243
+ if (revision > SESSION_DISCOVERY_REVISION_MAX) {
2244
+ throw new Error(`sessions_list ${label} exceeds the database activity revision range`);
2245
+ }
2246
+ return revision.toString();
2247
+ }
2248
+
2249
+ export function decodeSessionDiscoveryCursor(value: string): SessionDiscoveryCursor {
2250
+ try {
2251
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as {
2252
+ v?: unknown;
2253
+ orderBy?: unknown;
2254
+ sortRevision?: unknown;
2255
+ sortAt?: unknown;
2256
+ createdAt?: unknown;
2257
+ id?: unknown;
2258
+ snapshotAt?: unknown;
2259
+ snapshotRevision?: unknown;
2260
+ updatedAfter?: unknown;
2261
+ };
2262
+ if (
2263
+ parsed.v === undefined &&
2264
+ typeof parsed.createdAt === "string" &&
2265
+ typeof parsed.id === "string" &&
2266
+ SESSION_DISCOVERY_UUID.test(parsed.id)
2267
+ ) {
2268
+ const createdAt = normalizeSessionDiscoveryTimestamp(
2269
+ parsed.createdAt,
2270
+ "legacy cursor createdAt",
2271
+ );
2272
+ return {
2273
+ orderBy: "createdAt",
2274
+ sortRevision: "0",
2275
+ sortAt: createdAt,
2276
+ id: parsed.id,
2277
+ snapshotAt: createdAt,
2278
+ snapshotRevision: "0",
2279
+ updatedAfter: null,
2280
+ };
2281
+ }
2282
+ // The timestamp-fenced v1 format was never safe for updated-order
2283
+ // continuation. Preserve rolling compatibility only for creation cursors,
2284
+ // whose immutable ordering does not need an activity revision.
2285
+ if (
2286
+ parsed.v === 1 &&
2287
+ parsed.orderBy === "createdAt" &&
2288
+ typeof parsed.sortAt === "string" &&
2289
+ typeof parsed.snapshotAt === "string" &&
2290
+ parsed.updatedAfter === null &&
2291
+ typeof parsed.id === "string" &&
2292
+ SESSION_DISCOVERY_UUID.test(parsed.id)
2293
+ ) {
2294
+ return {
2295
+ orderBy: "createdAt",
2296
+ sortRevision: "0",
2297
+ sortAt: normalizeSessionDiscoveryTimestamp(parsed.sortAt, "cursor sortAt"),
2298
+ id: parsed.id,
2299
+ snapshotAt: normalizeSessionDiscoveryTimestamp(parsed.snapshotAt, "cursor snapshotAt"),
2300
+ snapshotRevision: "0",
2301
+ updatedAfter: null,
2302
+ };
2303
+ }
2304
+ if (
2305
+ parsed.v !== 2 ||
2306
+ (parsed.orderBy !== "createdAt" && parsed.orderBy !== "updatedAt") ||
2307
+ typeof parsed.sortRevision !== "string" ||
2308
+ typeof parsed.sortAt !== "string" ||
2309
+ typeof parsed.snapshotAt !== "string" ||
2310
+ typeof parsed.snapshotRevision !== "string" ||
2311
+ (parsed.updatedAfter !== null && typeof parsed.updatedAfter !== "string") ||
2312
+ typeof parsed.id !== "string" ||
2313
+ !SESSION_DISCOVERY_UUID.test(parsed.id)
2314
+ ) {
2315
+ throw new Error("invalid cursor fields");
2316
+ }
2317
+ const sortAt = normalizeSessionDiscoveryTimestamp(parsed.sortAt, "cursor sortAt");
2318
+ const snapshotAt = normalizeSessionDiscoveryTimestamp(parsed.snapshotAt, "cursor snapshotAt");
2319
+ const sortRevision = normalizeSessionDiscoveryRevision(
2320
+ parsed.sortRevision,
2321
+ "cursor sortRevision",
2322
+ );
2323
+ const snapshotRevision = normalizeSessionDiscoveryRevision(
2324
+ parsed.snapshotRevision,
2325
+ "cursor snapshotRevision",
2326
+ );
2327
+ const normalizedUpdatedAfter =
2328
+ parsed.updatedAfter === null
2329
+ ? null
2330
+ : normalizeSessionDiscoveryRevision(parsed.updatedAfter, "cursor updatedAfter");
2331
+ if (normalizedUpdatedAfter !== null && parsed.orderBy !== "updatedAt") {
2332
+ throw new Error("incremental cursor requires updatedAt order");
2333
+ }
2334
+ if (parsed.orderBy === "createdAt" && (sortRevision !== "0" || snapshotRevision !== "0")) {
2335
+ throw new Error("creation cursor cannot carry activity revisions");
2336
+ }
2337
+ return {
2338
+ orderBy: parsed.orderBy,
2339
+ sortRevision,
2340
+ sortAt,
2341
+ id: parsed.id,
2342
+ snapshotAt,
2343
+ snapshotRevision,
2344
+ updatedAfter: normalizedUpdatedAfter,
2345
+ };
2346
+ } catch {
2347
+ throw new Error("sessions_list cursor is invalid");
2348
+ }
2349
+ }
2350
+
2351
+ function capSessionDiscoveryText(
2352
+ value: string | null,
2353
+ maxChars = SESSION_DISCOVERY_TEXT_CHARS,
2354
+ originalChars?: number | null,
2355
+ ) {
2356
+ if (value === null) {
2357
+ return { text: value, truncated: false };
2358
+ }
2359
+ const projectedChars = Array.from(value);
2360
+ const sourceChars = Math.max(projectedChars.length, originalChars ?? projectedChars.length);
2361
+ if (sourceChars <= maxChars) {
2362
+ return { text: value, truncated: false };
2363
+ }
2364
+
2365
+ // The database supplies only a bounded prefix plus the original character
2366
+ // count. Iterate to a stable marker width so the reported omission includes
2367
+ // the characters replaced by the marker itself.
2368
+ let bodyChars = maxChars;
2369
+ let marker = "";
2370
+ for (let attempt = 0; attempt < 4; attempt += 1) {
2371
+ const omittedChars = Math.max(0, sourceChars - bodyChars);
2372
+ marker = `…[${omittedChars} chars truncated]…`;
2373
+ const nextBodyChars = Math.max(0, maxChars - Array.from(marker).length);
2374
+ if (nextBodyChars === bodyChars) break;
2375
+ bodyChars = nextBodyChars;
2376
+ }
2377
+ return {
2378
+ text: `${projectedChars.slice(0, bodyChars).join("")}${marker}`,
2379
+ truncated: true,
2380
+ };
2381
+ }
2382
+
2383
+ export function capSessionDiscoveryPage(
2384
+ page: Awaited<ReturnType<typeof listSessionDiscoverySummaries>>,
2385
+ includeLastMessage: boolean,
2386
+ ) {
2387
+ const projected = page.sessions.map((session) => {
2388
+ const title = capSessionDiscoveryText(session.title, 200, session.titleOriginalChars);
2389
+ const goal = session.goal
2390
+ ? capSessionDiscoveryText(
2391
+ session.goal.text,
2392
+ SESSION_DISCOVERY_TEXT_CHARS,
2393
+ session.goal.textOriginalChars,
2394
+ )
2395
+ : null;
2396
+ const preview = includeLastMessage
2397
+ ? capSessionDiscoveryText(
2398
+ session.latestMessage?.preview ?? null,
2399
+ SESSION_DISCOVERY_TEXT_CHARS,
2400
+ session.latestMessage?.previewOriginalChars,
2401
+ )
2402
+ : null;
2403
+ const blocker = session.effectiveControl.primaryBlocker;
2404
+ const blockerDisplayName = blocker
2405
+ ? capSessionDiscoveryText(blocker.displayName, 200, blocker.displayNameOriginalChars)
2406
+ : null;
2407
+ return {
2408
+ id: session.id,
2409
+ title: title.text,
2410
+ titleTruncated: title.truncated,
2411
+ parentSessionId: session.parentSessionId,
2412
+ isRoot: session.parentSessionId === null,
2413
+ status: session.status,
2414
+ pause: {
2415
+ state: session.effectiveControl.state,
2416
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
2417
+ source: blocker
2418
+ ? {
2419
+ kind: blocker.kind,
2420
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
2421
+ displayName: blockerDisplayName!.text,
2422
+ displayNameTruncated: blockerDisplayName!.truncated,
2423
+ }
2424
+ : null,
2425
+ },
2426
+ goal: session.goal
2427
+ ? {
2428
+ status: session.goal.status,
2429
+ summary: goal!.text,
2430
+ summaryTruncated: goal!.truncated,
2431
+ }
2432
+ : null,
2433
+ queuedPromptCount: session.queuedPromptCount,
2434
+ children: session.treeStats,
2435
+ ...(includeLastMessage
2436
+ ? {
2437
+ latestMessage: session.latestMessage
2438
+ ? {
2439
+ type: session.latestMessage.type,
2440
+ preview: preview!.text,
2441
+ previewTruncated: preview!.truncated,
2442
+ }
2443
+ : null,
2444
+ }
2445
+ : {}),
2446
+ createdAt: session.createdAt,
2447
+ updatedAt: session.updatedAt,
2448
+ };
2449
+ });
2450
+
2451
+ // The database order is already the useful discovery order. Spend the
2452
+ // separate preview budget in that order so the first page retains previews
2453
+ // for the most relevant rows and later rows remain discoverable by status.
2454
+ let budgetBytes = 0;
2455
+ const budgeted = includeLastMessage
2456
+ ? projected.map((session) => {
2457
+ const latestMessage = session.latestMessage;
2458
+ if (!latestMessage || latestMessage.preview === null) return session;
2459
+ const candidateBytes = Buffer.byteLength(latestMessage.preview, "utf8");
2460
+ if (budgetBytes + candidateBytes <= SESSION_DISCOVERY_PREVIEW_MAX_BYTES) {
2461
+ budgetBytes += candidateBytes;
2462
+ return session;
2463
+ }
2464
+ return {
2465
+ ...session,
2466
+ latestMessage: {
2467
+ ...latestMessage,
2468
+ preview: null,
2469
+ previewOmitted: true,
2470
+ previewOmissionReason: SESSION_DISCOVERY_PREVIEW_OMISSION_REASON,
2471
+ previewDrillDownTool: SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL,
2472
+ previewDrillDownInput: sessionDiscoveryPreviewDrillDownInput(
2473
+ session.id,
2474
+ latestMessage.type,
2475
+ ),
2476
+ },
2477
+ };
2478
+ })
2479
+ : projected;
2480
+
2481
+ let kept = budgeted;
2482
+ const build = () => {
2483
+ const previewBytes = includeLastMessage
2484
+ ? kept.reduce(
2485
+ (total, session) =>
2486
+ total +
2487
+ (session.latestMessage?.preview === null || !session.latestMessage
2488
+ ? 0
2489
+ : Buffer.byteLength(session.latestMessage.preview, "utf8")),
2490
+ 0,
2491
+ )
2492
+ : 0;
2493
+ const previewOmittedCount = includeLastMessage
2494
+ ? kept.filter((session) => {
2495
+ const latestMessage = session.latestMessage;
2496
+ return (
2497
+ latestMessage != null &&
2498
+ "previewOmitted" in latestMessage &&
2499
+ latestMessage.previewOmitted === true
2500
+ );
2501
+ }).length
2502
+ : 0;
2503
+ const lastKept = kept.at(-1);
2504
+ const droppedForByteCap = kept.length < projected.length;
2505
+ const sourceLast = lastKept
2506
+ ? page.sessions.find((session) => session.id === lastKept.id)
2507
+ : undefined;
2508
+ const nextCursor = droppedForByteCap
2509
+ ? sourceLast
2510
+ ? encodeSessionDiscoveryCursor({
2511
+ orderBy: page.orderBy,
2512
+ sortRevision: sourceLast.sortRevision,
2513
+ sortAt: sourceLast.sortAt,
2514
+ id: sourceLast.id,
2515
+ snapshotAt: page.snapshotAt,
2516
+ snapshotRevision: page.snapshotRevision,
2517
+ updatedAfter: page.updatedAfter,
2518
+ })
2519
+ : null
2520
+ : page.nextCursor
2521
+ ? encodeSessionDiscoveryCursor(page.nextCursor)
2522
+ : null;
2523
+ const result = {
2524
+ sessions: kept,
2525
+ total: page.total,
2526
+ hasMore: page.hasMore || droppedForByteCap,
2527
+ nextCursor,
2528
+ orderBy: page.orderBy,
2529
+ snapshotAt: page.snapshotAt,
2530
+ snapshotRevision: page.snapshotRevision,
2531
+ updatedAfter: page.updatedAfter,
2532
+ updatedThrough: page.updatedThrough,
2533
+ ...(includeLastMessage
2534
+ ? {
2535
+ latestMessagePreviewBudget: {
2536
+ bytes: previewBytes,
2537
+ maxBytes: SESSION_DISCOVERY_PREVIEW_MAX_BYTES,
2538
+ omittedCount: previewOmittedCount,
2539
+ truncated: previewOmittedCount > 0,
2540
+ omissionReason:
2541
+ previewOmittedCount > 0 ? SESSION_DISCOVERY_PREVIEW_OMISSION_REASON : null,
2542
+ drillDownTool: SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL,
2543
+ drillDownInput: {
2544
+ includeTypes: ["user.message", "agent.message.completed"] as const,
2545
+ ...SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT,
2546
+ },
2547
+ },
2548
+ }
2549
+ : {}),
2550
+ responseTruncated: droppedForByteCap,
2551
+ ...(droppedForByteCap
2552
+ ? {
2553
+ truncationReason: `response exceeded ${SESSION_DISCOVERY_PAGE_MAX_BYTES} bytes; continue with nextCursor`,
2554
+ }
2555
+ : {}),
2556
+ bytes: 0,
2557
+ maxBytes: SESSION_DISCOVERY_PAGE_MAX_BYTES,
2558
+ };
2559
+ for (let attempt = 0; attempt < 8; attempt += 1) {
2560
+ const measured = Buffer.byteLength(JSON.stringify(result, null, 2), "utf8");
2561
+ if (result.bytes === measured) break;
2562
+ result.bytes = measured;
2563
+ }
2564
+ return result;
2565
+ };
2566
+
2567
+ let result = build();
2568
+ while (result.bytes > SESSION_DISCOVERY_PAGE_MAX_BYTES && kept.length > 1) {
2569
+ kept = kept.slice(0, -1);
2570
+ result = build();
2571
+ }
2572
+ if (result.bytes > SESSION_DISCOVERY_PAGE_MAX_BYTES) {
2573
+ throw new RangeError(
2574
+ `sessions_list metadata exceeds its ${SESSION_DISCOVERY_PAGE_MAX_BYTES}-byte envelope`,
2575
+ );
2576
+ }
2577
+ return result;
2578
+ }
2579
+
1910
2580
  function parseMcpDate(raw: string, label: string): Date {
1911
2581
  const date = new Date(raw);
1912
2582
  if (Number.isNaN(date.getTime())) {
@@ -1914,3 +2584,15 @@ function parseMcpDate(raw: string, label: string): Date {
1914
2584
  }
1915
2585
  return date;
1916
2586
  }
2587
+
2588
+ async function withMcpEffectivePolicy(
2589
+ deps: ApiRouteDeps,
2590
+ workspaceId: string,
2591
+ session: Session,
2592
+ ): Promise<Session> {
2593
+ const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
2594
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
2595
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
2596
+ ]);
2597
+ return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
2598
+ }