@opengeni/api-router 0.5.2 → 0.5.4

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 (48) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
  3. package/dist/chunk-DO2G3JSB.js.map +1 -0
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +297 -54
  6. package/dist/index.js.map +1 -1
  7. package/package.json +21 -21
  8. package/src/app.ts +415 -147
  9. package/src/auth/managed-auth.ts +32 -16
  10. package/src/http/auth.ts +8 -1
  11. package/src/http/common.ts +6 -2
  12. package/src/http/sse.ts +27 -6
  13. package/src/index.ts +196 -74
  14. package/src/integrations/oauth-client.ts +592 -131
  15. package/src/integrations/provider-domain.ts +4 -1
  16. package/src/mcp/documents.ts +173 -94
  17. package/src/mcp/server.ts +1517 -692
  18. package/src/mcp/session-view.ts +8 -2
  19. package/src/mcp/toolspace.ts +175 -84
  20. package/src/observability.ts +7 -1
  21. package/src/routes/api-keys.ts +39 -23
  22. package/src/routes/billing.ts +180 -65
  23. package/src/routes/capabilities.ts +17 -8
  24. package/src/routes/catalog-assets.ts +5 -2
  25. package/src/routes/codex.ts +244 -63
  26. package/src/routes/connections.ts +72 -34
  27. package/src/routes/documents.ts +242 -92
  28. package/src/routes/enrollments.ts +100 -70
  29. package/src/routes/environments.ts +205 -136
  30. package/src/routes/files.ts +164 -39
  31. package/src/routes/github.ts +123 -50
  32. package/src/routes/install.ts +9 -2
  33. package/src/routes/machines.ts +9 -8
  34. package/src/routes/packs.ts +141 -89
  35. package/src/routes/rigs.ts +189 -0
  36. package/src/routes/scheduled-tasks.ts +51 -9
  37. package/src/routes/sessions.ts +839 -329
  38. package/src/routes/social.ts +50 -38
  39. package/src/routes/workspace-capture.ts +238 -0
  40. package/src/routes/workspaces.ts +159 -13
  41. package/src/sandbox/access.ts +11 -3
  42. package/src/sandbox/auth-callout.ts +5 -1
  43. package/src/sandbox/channel-a.ts +104 -27
  44. package/src/sandbox/enrollment.ts +13 -3
  45. package/src/sandbox/machines.ts +68 -59
  46. package/src/sandbox/metrics-ingestion.ts +238 -17
  47. package/src/sandbox/viewer.ts +172 -46
  48. package/dist/chunk-YY6OAEL6.js.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
3
  SessionMcpCredentialUpdateInput,
4
- WorkspaceEnvironmentVariableName,
4
+ VariableSetVariableName,
5
5
  type AccessGrant,
6
6
  type GitHubRepository,
7
7
  type Permission,
@@ -9,30 +9,44 @@ import {
9
9
  UpdateScheduledTaskRequest,
10
10
  } from "@opengeni/contracts";
11
11
  import {
12
- countWorkspaceEnvironments,
13
- createWorkspaceEnvironment,
12
+ addSessionSystemUpdate,
13
+ correctWorkspaceMemory,
14
+ countVariableSets,
15
+ beginRigChangeVerificationAttempt,
16
+ createVariableSet,
14
17
  deleteScheduledTask,
15
- encryptEnvironmentValue,
18
+ encryptVariableSetValue,
16
19
  getSession,
17
20
  getSessionGoal,
18
- getWorkspaceEnvironment,
19
- getWorkspaceEnvironmentByName,
21
+ getSessionTurn,
22
+ getVariableSet,
23
+ getVariableSetByName,
20
24
  listGitHubInstallationIdsForWorkspace,
21
25
  listScheduledTaskRuns,
22
26
  listScheduledTasks,
23
27
  listSessionEvents,
24
28
  listSessions,
29
+ listRigs,
25
30
  listSocialConnections,
26
31
  listSocialPosts,
27
- listWorkspaceEnvironments,
32
+ listVariableSets,
33
+ MEMORY_CORRECT_TOOL_DESCRIPTION,
34
+ MEMORY_SAVE_TOOL_DESCRIPTION,
35
+ MEMORY_SEARCH_TOOL_DESCRIPTION,
28
36
  requireFile,
29
37
  requireScheduledTask,
30
38
  requireSession,
39
+ requestSessionControl,
40
+ saveWorkspaceMemory,
41
+ searchWorkspaceMemories,
42
+ setSessionChildNotificationsMode,
31
43
  setSessionGoalStatus,
32
- setWorkspaceEnvironmentVariable,
44
+ setVariableSetVariable,
33
45
  updateScheduledTask,
34
46
  updateSessionGoal,
35
47
  upsertSessionGoal,
48
+ RigChangeAlreadyVerifyingError,
49
+ RigChangeTransitionError,
36
50
  } from "@opengeni/db";
37
51
  import { appendAndPublishEvents } from "@opengeni/events";
38
52
  import {
@@ -49,11 +63,17 @@ import { hasPermission } from "@opengeni/core";
49
63
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
50
64
  import type { ApiRouteDeps } from "@opengeni/core";
51
65
  import {
52
- assertAllowedEnvironmentVariableName,
66
+ listRigChangesForApi,
67
+ listRigVersionsForApi,
68
+ promoteVerifiedDefinitionEditChangeForApi,
69
+ proposeRigChangeForApi,
70
+ requireRigChangeForApi,
71
+ requireRigForApi,
72
+ assertAllowedVariableSetVariableName,
53
73
  MAX_ENVIRONMENTS_PER_WORKSPACE,
54
74
  MAX_VARIABLES_PER_ENVIRONMENT,
55
- recordEnvironmentAuditEvent,
56
- requireEnvironmentEncryption,
75
+ recordVariableSetAuditEvent,
76
+ requireVariableSetEncryption,
57
77
  } from "@opengeni/core";
58
78
  import {
59
79
  createValidatedScheduledTask,
@@ -65,7 +85,12 @@ import {
65
85
  syncUpdatedScheduledTask,
66
86
  validatedScheduledTaskUpdate,
67
87
  } from "@opengeni/core";
68
- import { acceptSessionUserMessage, createSessionForRequest, updateSessionTitle, workflowIdForSession } from "@opengeni/core";
88
+ import {
89
+ acceptSessionUserMessage,
90
+ createSessionForRequest,
91
+ updateSessionTitle,
92
+ workflowIdForSession,
93
+ } from "@opengeni/core";
69
94
  import {
70
95
  buildFleetContextForSession,
71
96
  listFleet,
@@ -85,36 +110,77 @@ export type McpServerOptions = {
85
110
  // OPENGENI_PUBLIC_BASE_URL nor the manifest base URL is configured.
86
111
  requestOrigin?: string | null;
87
112
  toolspace?: ToolspaceMcpSurface | null;
113
+ workspaceMemoryEnabled?: boolean | undefined;
88
114
  };
89
115
 
90
- export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, options: McpServerOptions = {}): McpServer {
116
+ export function buildOpenGeniMcpServer(
117
+ deps: ApiRouteDeps,
118
+ grant: AccessGrant,
119
+ options: McpServerOptions = {},
120
+ ): McpServer {
91
121
  const server = new McpServer({
92
122
  name: "opengeni",
93
123
  version: "1.0.0",
94
124
  });
95
- const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] });
125
+ const json = (value: unknown) => ({
126
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
127
+ });
96
128
  const can = (permission: Permission) => hasPermission(grant.permissions, permission);
97
129
  const toolspaceMode = options.toolspace != null;
98
130
 
99
131
  // Session-scoped tools key off the worker-asserted sessionId claim (signed
100
132
  // into the delegated token by the worker, never agent-controlled).
101
- const sessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
133
+ const sessionId =
134
+ typeof grant.metadata?.["sessionId"] === "string"
135
+ ? (grant.metadata["sessionId"] as string)
136
+ : null;
102
137
  // set_session_title names the agent's OWN session — pure session metadata,
103
138
  // not a goal operation — so it is available on every session, gated only on
104
139
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
105
140
  if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
106
- server.registerTool("set_session_title", {
107
- description: "Set this session's display title to a concise 3-7 word summary. Call once early to name the session; calling again replaces it unless a human has manually set the title.",
108
- inputSchema: { title: z4.string().min(1).max(200) },
109
- }, async ({ title }) => {
110
- const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
111
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
112
- });
141
+ server.registerTool(
142
+ "set_session_title",
143
+ {
144
+ description:
145
+ "Set this session's display title to a concise 3-7 word summary. The title persists across turns: call once on a new untitled session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
146
+ inputSchema: { title: z4.string().min(1).max(200) },
147
+ },
148
+ async ({ title }) => {
149
+ const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
150
+ return json({ ok: true, updated: result.updated, title: result.title ?? title });
151
+ },
152
+ );
153
+ }
154
+ if (sessionId !== null && !toolspaceMode && can("sessions:create")) {
155
+ server.registerTool(
156
+ "set_child_notifications_mode",
157
+ {
158
+ description:
159
+ "Change how workers you spawn report back when they finish. This setting persists across turns and must not be re-applied as routine setup or recovery. 'digest' (default): completions arrive as a coalesced turn you process. 'passive': completions appear only as quiet cards and never queue a turn or model run. Call only when the desired mode differs from the mode already in effect.",
160
+ inputSchema: { mode: z4.enum(["digest", "passive"]) },
161
+ },
162
+ async ({ mode }) => {
163
+ const changed = await setSessionChildNotificationsMode(
164
+ deps.db,
165
+ grant.workspaceId,
166
+ sessionId,
167
+ mode,
168
+ );
169
+ return json({ ok: true, changed, mode });
170
+ },
171
+ );
113
172
  }
114
173
  // Goal tools require goals:manage (in the default first-party permission set).
115
174
  if (sessionId !== null && can("goals:manage")) {
116
175
  registerGoalTools(server, deps, grant, sessionId, json);
117
176
  }
177
+ // Toolspace grants are the sandbox's narrowed proxy surface. Unlike the
178
+ // normal first-party worker token, a bare toolspace:call token does not see
179
+ // unpermissioned session tools; memory follows that title/goal parity and
180
+ // stays on the normal first-party MCP surface only.
181
+ if (!toolspaceMode && sessionId !== null && options.workspaceMemoryEnabled === true) {
182
+ registerMemoryTools(server, deps, grant, sessionId, json);
183
+ }
118
184
 
119
185
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
120
186
  // provision over the session's Modal box + the workspace's enrolled machines.
@@ -125,18 +191,21 @@ export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, o
125
191
  if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
126
192
  registerFleetTools(server, deps, grant, sessionId, json);
127
193
  }
194
+ if (!toolspaceMode) {
195
+ registerRigTools(server, deps, grant, can, sessionId, json);
196
+ }
128
197
 
129
- // Orchestration, environment, and GitHub-connect tools are permission-gated
198
+ // Orchestration, variableSet, and GitHub-connect tools are permission-gated
130
199
  // at registration: a grant without the permission does not see the tool.
131
200
  // Sandboxed workers reach this server with the first-party delegated
132
201
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
133
- // POWERFUL BY DEFAULT — it carries sessions:*, environments:*, and github:use,
134
- // so agents can spawn/read sessions, manage workspace environment variables,
202
+ // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
203
+ // so agents can spawn/read sessions, manage variable set variables,
135
204
  // and mint GitHub install links out of the box. A user DEMOTES a specific
136
205
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
137
206
  // the creator's own grant); operators still cap what any session can be given.
138
- registerWorkspaceOrchestrationTools(server, deps, grant, can, json);
139
- registerEnvironmentTools(server, deps, grant, can, json);
207
+ registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, json);
208
+ registerVariableSetTools(server, deps, grant, can, json);
140
209
  if (can("github:use")) {
141
210
  registerGitHubConnectTool(server, deps, grant, options, json);
142
211
  // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
@@ -148,248 +217,397 @@ export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, o
148
217
  }
149
218
 
150
219
  if (!toolspaceMode || can("files:read")) {
151
- server.registerTool("files_get_download_url", {
152
- description: "Create a short-lived download URL for a ready file asset.",
153
- inputSchema: { fileId: z4.string().uuid() },
154
- }, async ({ fileId }) => {
155
- if (!deps.objectStorage) {
156
- throw new Error("object storage is not configured");
157
- }
158
- const file = await requireFile(deps.db, grant.workspaceId, fileId);
159
- if (file.status !== "ready") {
160
- throw new Error(`file is ${file.status}`);
161
- }
162
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
163
- return json({
164
- file: {
165
- id: file.id,
166
- filename: file.filename,
167
- safeFilename: file.safeFilename,
168
- contentType: file.contentType,
169
- sizeBytes: file.sizeBytes,
170
- sha256: file.sha256,
171
- status: file.status,
172
- createdAt: file.createdAt,
173
- updatedAt: file.updatedAt,
174
- },
175
- downloadUrl: {
176
- url: signed.url,
177
- expiresAt: signed.expiresAt.toISOString(),
220
+ server.registerTool(
221
+ "files_get_download_url",
222
+ {
223
+ description: "Create a short-lived download URL for a ready file asset.",
224
+ inputSchema: { fileId: z4.string().uuid() },
178
225
  },
179
- });
180
- });
226
+ async ({ fileId }) => {
227
+ if (!deps.objectStorage) {
228
+ throw new Error("object storage is not configured");
229
+ }
230
+ const file = await requireFile(deps.db, grant.workspaceId, fileId);
231
+ if (file.status !== "ready") {
232
+ throw new Error(`file is ${file.status}`);
233
+ }
234
+ const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
235
+ return json({
236
+ file: {
237
+ id: file.id,
238
+ filename: file.filename,
239
+ safeFilename: file.safeFilename,
240
+ contentType: file.contentType,
241
+ sizeBytes: file.sizeBytes,
242
+ sha256: file.sha256,
243
+ status: file.status,
244
+ createdAt: file.createdAt,
245
+ updatedAt: file.updatedAt,
246
+ },
247
+ downloadUrl: {
248
+ url: signed.url,
249
+ expiresAt: signed.expiresAt.toISOString(),
250
+ },
251
+ });
252
+ },
253
+ );
181
254
  }
182
255
 
183
256
  if (!toolspaceMode || can("github:use")) {
184
- server.registerTool("github_repositories_list", {
185
- description: "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
186
- inputSchema: { limit: z4.number().int().positive().optional() },
187
- }, async ({ limit }) => {
188
- try {
189
- const installationIds = await listGitHubInstallationIdsForWorkspace(deps.db, grant.workspaceId);
190
- const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
191
- const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
192
- return json({ repositories: visible.map((repository) => repositoryWithScheduledTaskResource(repository)) });
193
- } catch (error) {
194
- if (error instanceof GitHubAppConfigurationError) {
195
- throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`);
196
- }
197
- throw error;
198
- }
199
- });
257
+ server.registerTool(
258
+ "github_repositories_list",
259
+ {
260
+ description:
261
+ "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
262
+ inputSchema: { limit: z4.number().int().positive().optional() },
263
+ },
264
+ async ({ limit }) => {
265
+ try {
266
+ const installationIds = await listGitHubInstallationIdsForWorkspace(
267
+ deps.db,
268
+ grant.workspaceId,
269
+ );
270
+ const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
271
+ const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
272
+ return json({
273
+ repositories: visible.map((repository) =>
274
+ repositoryWithScheduledTaskResource(repository),
275
+ ),
276
+ });
277
+ } catch (error) {
278
+ if (error instanceof GitHubAppConfigurationError) {
279
+ throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`, {
280
+ cause: error,
281
+ });
282
+ }
283
+ throw error;
284
+ }
285
+ },
286
+ );
200
287
  }
201
288
 
202
289
  if (!toolspaceMode || can("connections:read")) {
203
- server.registerTool("social_connections_list", {
204
- description: "List connected social media accounts available to social media analysis packs.",
205
- inputSchema: { limit: z4.number().int().positive().optional() },
206
- }, async ({ limit }) => json({ connections: await listSocialConnections(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
207
-
208
- server.registerTool("social_posts_recent", {
209
- description: "List recent social media posts imported or synced into OpenGeni.",
210
- inputSchema: {
211
- connectionIds: z4.array(z4.string().uuid()).optional(),
212
- since: z4.string().optional(),
213
- windowHours: z4.number().int().positive().optional(),
214
- limit: z4.number().int().positive().optional(),
215
- },
216
- }, async ({ connectionIds, since, windowHours, limit }) => {
217
- const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
218
- return json({
219
- since: sinceDate.toISOString(),
220
- posts: await listSocialPosts(deps.db, {
221
- workspaceId: grant.workspaceId,
222
- ...(connectionIds?.length ? { connectionIds } : {}),
223
- since: sinceDate,
224
- limit: boundedMcpLimit(limit),
225
- }),
226
- });
227
- });
290
+ server.registerTool(
291
+ "social_connections_list",
292
+ {
293
+ description:
294
+ "List connected social media accounts available to social media analysis packs.",
295
+ inputSchema: { limit: z4.number().int().positive().optional() },
296
+ },
297
+ async ({ limit }) =>
298
+ json({
299
+ connections: await listSocialConnections(
300
+ deps.db,
301
+ grant.workspaceId,
302
+ boundedMcpLimit(limit),
303
+ ),
304
+ }),
305
+ );
228
306
 
229
- server.registerTool("social_daily_analysis_context", {
230
- description: "Collect social account and recent post context for a daily marketing analysis run.",
231
- inputSchema: {
232
- connectionIds: z4.array(z4.string().uuid()).optional(),
233
- documentBaseIds: z4.array(z4.string().uuid()).optional(),
234
- since: z4.string().optional(),
235
- windowHours: z4.number().int().positive().optional(),
236
- limit: z4.number().int().positive().optional(),
237
- },
238
- }, async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
239
- const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
240
- const selectedIds = connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
241
- const connections = selectedIds
242
- ? allConnections.filter((connection) => selectedIds.has(connection.id))
243
- : allConnections.filter((connection) => connection.status === "connected");
244
- if (selectedIds) {
245
- const foundIds = new Set(connections.map((connection) => connection.id));
246
- const missing = [...selectedIds].filter((id) => !foundIds.has(id));
247
- if (missing.length > 0) {
248
- throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
249
- }
250
- }
251
- const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
252
- const posts = connections.length > 0
253
- ? await listSocialPosts(deps.db, {
254
- workspaceId: grant.workspaceId,
255
- connectionIds: connections.map((connection) => connection.id),
256
- since: sinceDate,
257
- limit: boundedMcpLimit(limit),
258
- })
259
- : [];
260
- return json({
261
- generatedAt: new Date().toISOString(),
262
- window: {
263
- since: sinceDate.toISOString(),
264
- until: new Date().toISOString(),
265
- },
266
- documentBaseIds: documentBaseIds ?? [],
267
- connections,
268
- posts,
269
- instructions: [
270
- "Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
271
- "Report data gaps explicitly when posts or metrics are missing.",
272
- "Do not infer unpublished metrics or hidden platform data.",
273
- ],
274
- });
275
- });
307
+ server.registerTool(
308
+ "social_posts_recent",
309
+ {
310
+ description: "List recent social media posts imported or synced into OpenGeni.",
311
+ inputSchema: {
312
+ connectionIds: z4.array(z4.string().uuid()).optional(),
313
+ since: z4.string().optional(),
314
+ windowHours: z4.number().int().positive().optional(),
315
+ limit: z4.number().int().positive().optional(),
316
+ },
317
+ },
318
+ async ({ connectionIds, since, windowHours, limit }) => {
319
+ const sinceDate = since
320
+ ? parseMcpDate(since, "since")
321
+ : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
322
+ return json({
323
+ since: sinceDate.toISOString(),
324
+ posts: await listSocialPosts(deps.db, {
325
+ workspaceId: grant.workspaceId,
326
+ ...(connectionIds?.length ? { connectionIds } : {}),
327
+ since: sinceDate,
328
+ limit: boundedMcpLimit(limit),
329
+ }),
330
+ });
331
+ },
332
+ );
276
333
 
334
+ server.registerTool(
335
+ "social_daily_analysis_context",
336
+ {
337
+ description:
338
+ "Collect social account and recent post context for a daily marketing analysis run.",
339
+ inputSchema: {
340
+ connectionIds: z4.array(z4.string().uuid()).optional(),
341
+ documentBaseIds: z4.array(z4.string().uuid()).optional(),
342
+ since: z4.string().optional(),
343
+ windowHours: z4.number().int().positive().optional(),
344
+ limit: z4.number().int().positive().optional(),
345
+ },
346
+ },
347
+ async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
348
+ const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
349
+ const selectedIds =
350
+ connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
351
+ const connections = selectedIds
352
+ ? allConnections.filter((connection) => selectedIds.has(connection.id))
353
+ : allConnections.filter((connection) => connection.status === "connected");
354
+ if (selectedIds) {
355
+ const foundIds = new Set(connections.map((connection) => connection.id));
356
+ const missing = [...selectedIds].filter((id) => !foundIds.has(id));
357
+ if (missing.length > 0) {
358
+ throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
359
+ }
360
+ }
361
+ const sinceDate = since
362
+ ? parseMcpDate(since, "since")
363
+ : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
364
+ const posts =
365
+ connections.length > 0
366
+ ? await listSocialPosts(deps.db, {
367
+ workspaceId: grant.workspaceId,
368
+ connectionIds: connections.map((connection) => connection.id),
369
+ since: sinceDate,
370
+ limit: boundedMcpLimit(limit),
371
+ })
372
+ : [];
373
+ return json({
374
+ generatedAt: new Date().toISOString(),
375
+ window: {
376
+ since: sinceDate.toISOString(),
377
+ until: new Date().toISOString(),
378
+ },
379
+ documentBaseIds: documentBaseIds ?? [],
380
+ connections,
381
+ posts,
382
+ instructions: [
383
+ "Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
384
+ "Report data gaps explicitly when posts or metrics are missing.",
385
+ "Do not infer unpublished metrics or hidden platform data.",
386
+ ],
387
+ });
388
+ },
389
+ );
277
390
  }
278
391
 
279
392
  if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
280
- server.registerTool("scheduled_tasks_list", {
281
- description: "List scheduled tasks.",
282
- inputSchema: { limit: z4.number().int().positive().optional() },
283
- }, async ({ limit }) => json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }));
284
-
285
- server.registerTool("scheduled_tasks_get", {
286
- description: "Get one scheduled task.",
287
- inputSchema: { id: z4.string().uuid() },
288
- }, async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)));
289
-
290
- server.registerTool("scheduled_tasks_create", {
291
- description: "Create a scheduled task.",
292
- inputSchema: {
293
- name: z4.string(),
294
- schedule: z4.unknown(),
295
- runMode: z4.string().optional(),
296
- overlapPolicy: z4.string().optional(),
297
- agentConfig: z4.unknown(),
298
- status: z4.string().optional(),
299
- environmentId: z4.string().uuid().optional(),
300
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
301
- },
302
- }, async (args) => {
303
- const payload = CreateScheduledTaskRequest.parse(args);
304
- requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
305
- await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "schedule:create", quantity: 1 });
306
- const task = await createValidatedScheduledTask({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, payload, toolsProvided: scheduledTaskToolsProvided(args) });
307
- await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
308
- return json(task);
309
- });
393
+ server.registerTool(
394
+ "scheduled_tasks_list",
395
+ {
396
+ description: "List scheduled tasks.",
397
+ inputSchema: { limit: z4.number().int().positive().optional() },
398
+ },
399
+ async ({ limit }) =>
400
+ json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }),
401
+ );
310
402
 
311
- server.registerTool("scheduled_tasks_update", {
312
- description: "Update a scheduled task.",
313
- inputSchema: {
314
- id: z4.string().uuid(),
315
- name: z4.string().optional(),
316
- schedule: z4.unknown().optional(),
317
- runMode: z4.string().optional(),
318
- overlapPolicy: z4.string().optional(),
319
- agentConfig: z4.unknown().optional(),
320
- status: z4.string().optional(),
321
- environmentId: z4.string().uuid().nullable().optional(),
322
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
323
- },
324
- }, async ({ id, ...raw }) => {
325
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
326
- const payload = UpdateScheduledTaskRequest.parse(raw);
327
- requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
328
- const update = await validatedScheduledTaskUpdate({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, existing, payload, toolsProvided: scheduledTaskToolsProvided(raw) });
329
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
330
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
331
- return json(task);
332
- });
403
+ server.registerTool(
404
+ "scheduled_tasks_get",
405
+ {
406
+ description: "Get one scheduled task.",
407
+ inputSchema: { id: z4.string().uuid() },
408
+ },
409
+ async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)),
410
+ );
333
411
 
334
- server.registerTool("scheduled_tasks_pause", {
335
- description: "Pause a scheduled task.",
336
- inputSchema: { id: z4.string().uuid() },
337
- }, async ({ id }) => {
338
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
339
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "paused" });
340
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
341
- return json(task);
342
- });
412
+ server.registerTool(
413
+ "scheduled_tasks_create",
414
+ {
415
+ description: "Create a scheduled task.",
416
+ inputSchema: {
417
+ name: z4.string(),
418
+ schedule: z4.unknown(),
419
+ runMode: z4.string().optional(),
420
+ overlapPolicy: z4.string().optional(),
421
+ agentConfig: z4.unknown(),
422
+ status: z4.string().optional(),
423
+ variableSetId: z4.string().uuid().optional(),
424
+ // Deprecated alias of variableSetId; declared so MCP validation doesn't
425
+ // strip it before the contract parse maps it (rename back-compat).
426
+ environmentId: z4.string().uuid().optional(),
427
+ // Bind the task to a rig; declared so MCP validation doesn't strip it.
428
+ rigId: z4.string().uuid().nullable().optional(),
429
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
430
+ },
431
+ },
432
+ async (args) => {
433
+ const payload = CreateScheduledTaskRequest.parse(args);
434
+ requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
435
+ await requireLimit(deps, {
436
+ accountId: grant.accountId,
437
+ workspaceId: grant.workspaceId,
438
+ action: "schedule:create",
439
+ quantity: 1,
440
+ });
441
+ const task = await createValidatedScheduledTask({
442
+ settings: deps.settings,
443
+ db: deps.db,
444
+ objectStorage: deps.objectStorage,
445
+ grant,
446
+ payload,
447
+ toolsProvided: scheduledTaskToolsProvided(args),
448
+ });
449
+ await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
450
+ return json(task);
451
+ },
452
+ );
343
453
 
344
- server.registerTool("scheduled_tasks_resume", {
345
- description: "Resume a scheduled task.",
346
- inputSchema: { id: z4.string().uuid() },
347
- }, async ({ id }) => {
348
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
349
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "active" });
350
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
351
- return json(task);
352
- });
454
+ server.registerTool(
455
+ "scheduled_tasks_update",
456
+ {
457
+ description: "Update a scheduled task.",
458
+ inputSchema: {
459
+ id: z4.string().uuid(),
460
+ name: z4.string().optional(),
461
+ schedule: z4.unknown().optional(),
462
+ runMode: z4.string().optional(),
463
+ overlapPolicy: z4.string().optional(),
464
+ agentConfig: z4.unknown().optional(),
465
+ status: z4.string().optional(),
466
+ variableSetId: z4.string().uuid().nullable().optional(),
467
+ // Deprecated alias of variableSetId (rename back-compat); declared so MCP
468
+ // validation doesn't strip it before the contract parse maps it.
469
+ environmentId: z4.string().uuid().nullable().optional(),
470
+ // Bind the task to a rig; declared so MCP validation doesn't strip it.
471
+ rigId: z4.string().uuid().nullable().optional(),
472
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
473
+ },
474
+ },
475
+ async ({ id, ...raw }) => {
476
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
477
+ const payload = UpdateScheduledTaskRequest.parse(raw);
478
+ requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
479
+ const update = await validatedScheduledTaskUpdate({
480
+ settings: deps.settings,
481
+ db: deps.db,
482
+ objectStorage: deps.objectStorage,
483
+ grant,
484
+ existing,
485
+ payload,
486
+ toolsProvided: scheduledTaskToolsProvided(raw),
487
+ });
488
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
489
+ await syncUpdatedScheduledTask({
490
+ db: deps.db,
491
+ workflowClient: deps.workflowClient,
492
+ previous: existing,
493
+ task,
494
+ });
495
+ return json(task);
496
+ },
497
+ );
353
498
 
354
- server.registerTool("scheduled_tasks_trigger", {
355
- description: "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
356
- inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
357
- }, async ({ id, triggerId }) => {
358
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
359
- await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
360
- const triggerToken = scheduledTaskTriggerToken(triggerId);
361
- const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken);
362
- const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
363
- await deps.workflowClient.triggerScheduledTask({ task, agentRunUsageIdempotencyKey, triggerWorkflowId });
364
- await recordWorkspaceUsage(deps, {
365
- accountId: grant.accountId,
366
- workspaceId: grant.workspaceId,
367
- subjectId: grant.subjectId,
368
- eventType: "agent_run.created",
369
- quantity: 1,
370
- unit: "run",
371
- sourceResourceType: "scheduled_task",
372
- sourceResourceId: task.id,
373
- idempotencyKey: agentRunUsageIdempotencyKey,
374
- });
375
- return json(task);
376
- });
499
+ server.registerTool(
500
+ "scheduled_tasks_pause",
501
+ {
502
+ description: "Pause a scheduled task.",
503
+ inputSchema: { id: z4.string().uuid() },
504
+ },
505
+ async ({ id }) => {
506
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
507
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
508
+ status: "paused",
509
+ });
510
+ await syncUpdatedScheduledTask({
511
+ db: deps.db,
512
+ workflowClient: deps.workflowClient,
513
+ previous: existing,
514
+ task,
515
+ });
516
+ return json(task);
517
+ },
518
+ );
377
519
 
378
- server.registerTool("scheduled_tasks_delete", {
379
- description: "Delete a scheduled task.",
380
- inputSchema: { id: z4.string().uuid() },
381
- }, async ({ id }) => {
382
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
383
- await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
384
- await deleteScheduledTask(deps.db, grant.workspaceId, id);
385
- return json({ ok: true });
386
- });
520
+ server.registerTool(
521
+ "scheduled_tasks_resume",
522
+ {
523
+ description: "Resume a scheduled task.",
524
+ inputSchema: { id: z4.string().uuid() },
525
+ },
526
+ async ({ id }) => {
527
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
528
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
529
+ status: "active",
530
+ });
531
+ await syncUpdatedScheduledTask({
532
+ db: deps.db,
533
+ workflowClient: deps.workflowClient,
534
+ previous: existing,
535
+ task,
536
+ });
537
+ return json(task);
538
+ },
539
+ );
387
540
 
388
- server.registerTool("scheduled_task_runs_list", {
389
- description: "List runs for a scheduled task.",
390
- inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
391
- }, async ({ taskId, limit }) => json({ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100) }));
541
+ server.registerTool(
542
+ "scheduled_tasks_trigger",
543
+ {
544
+ description:
545
+ "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
546
+ inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
547
+ },
548
+ async ({ id, triggerId }) => {
549
+ const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
550
+ await requireLimit(deps, {
551
+ accountId: grant.accountId,
552
+ workspaceId: grant.workspaceId,
553
+ action: "agent_run:create",
554
+ quantity: 1,
555
+ model: task.agentConfig.model ?? deps.settings.openaiModel,
556
+ });
557
+ const triggerToken = scheduledTaskTriggerToken(triggerId);
558
+ const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(
559
+ grant.workspaceId,
560
+ task.id,
561
+ triggerToken,
562
+ );
563
+ const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
564
+ await deps.workflowClient.triggerScheduledTask({
565
+ task,
566
+ agentRunUsageIdempotencyKey,
567
+ triggerWorkflowId,
568
+ });
569
+ await recordWorkspaceUsage(deps, {
570
+ accountId: grant.accountId,
571
+ workspaceId: grant.workspaceId,
572
+ subjectId: grant.subjectId,
573
+ eventType: "agent_run.created",
574
+ quantity: 1,
575
+ unit: "run",
576
+ sourceResourceType: "scheduled_task",
577
+ sourceResourceId: task.id,
578
+ idempotencyKey: agentRunUsageIdempotencyKey,
579
+ });
580
+ return json(task);
581
+ },
582
+ );
392
583
 
584
+ server.registerTool(
585
+ "scheduled_tasks_delete",
586
+ {
587
+ description: "Delete a scheduled task.",
588
+ inputSchema: { id: z4.string().uuid() },
589
+ },
590
+ async ({ id }) => {
591
+ const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
592
+ await deps.workflowClient.deleteScheduledTaskSchedule({
593
+ temporalScheduleId: task.temporalScheduleId,
594
+ });
595
+ await deleteScheduledTask(deps.db, grant.workspaceId, id);
596
+ return json({ ok: true });
597
+ },
598
+ );
599
+
600
+ server.registerTool(
601
+ "scheduled_task_runs_list",
602
+ {
603
+ description: "List runs for a scheduled task.",
604
+ inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
605
+ },
606
+ async ({ taskId, limit }) =>
607
+ json({
608
+ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100),
609
+ }),
610
+ );
393
611
  }
394
612
 
395
613
  registerToolspaceProxyTools(server, options.toolspace ?? null);
@@ -402,18 +620,67 @@ function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSur
402
620
  return;
403
621
  }
404
622
  for (const tool of surface.tools) {
405
- server.registerTool(tool.name, {
406
- ...(tool.description ? { description: tool.description } : {}),
407
- inputSchema: z4.object({}).passthrough(),
408
- _meta: {
409
- opengeni: {
410
- origin: "toolspace",
411
- subjectId: surface.subjectId,
412
- sessionId: surface.sessionId,
413
- ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
623
+ server.registerTool(
624
+ tool.name,
625
+ {
626
+ ...(tool.description ? { description: tool.description } : {}),
627
+ inputSchema: z4.object({}).passthrough(),
628
+ _meta: {
629
+ opengeni: {
630
+ origin: "toolspace",
631
+ subjectId: surface.subjectId,
632
+ sessionId: surface.sessionId,
633
+ ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
634
+ },
414
635
  },
415
636
  },
416
- }, async (args) => await tool.call(args));
637
+ async (args) => await tool.call(args),
638
+ );
639
+ }
640
+ }
641
+
642
+ /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
643
+ export function isHumanDirectedTurn(turn: { source: string }): boolean {
644
+ return turn.source === "user" || turn.source === "api";
645
+ }
646
+
647
+ /**
648
+ * Sacred user pause: a goal a human paused (pausedReason 'user_pause') must
649
+ * never be resurrected by a MACHINE turn. Child-completion notification turns
650
+ * carry a "resume it now" nudge; without this guard the agent processing one of
651
+ * them would call goal_set and re-arm the exact autonomous loop the user just
652
+ * paused (the runaway that made Pause feel broken). A genuine user message
653
+ * still redirects freely (it is not a child-notification turn), and the
654
+ * human-driven API resume path (PATCH /goal) is unaffected.
655
+ *
656
+ * Classification is by CALLER IDENTITY — `callerTurnId` is the turn that minted
657
+ * this MCP token (signed into it by the worker at turn setup). We deliberately
658
+ * do NOT read the session's live `active_turn_id`: that pointer can flip to a
659
+ * different turn between reads (a machine turn ends and a human turn becomes
660
+ * active mid-check), which would misclassify the caller and, worst case, refuse
661
+ * a legitimate human `goal_set` — inverting the guard against the very human
662
+ * power it must preserve. A caller turn's source/metadata are immutable, so this
663
+ * read is race-free. No caller identity ⇒ fail OPEN (only a positively
664
+ * identified machine child-notification caller is refused).
665
+ */
666
+ export async function assertGoalReactivationAllowed(
667
+ deps: ApiRouteDeps,
668
+ workspaceId: string,
669
+ sessionId: string,
670
+ callerTurnId: string | null,
671
+ ): Promise<void> {
672
+ if (!callerTurnId) {
673
+ return;
674
+ }
675
+ const goal = await getSessionGoal(deps.db, workspaceId, sessionId);
676
+ if (!goal || goal.status !== "paused" || goal.pausedReason !== "user_pause") {
677
+ return;
678
+ }
679
+ const turn = await getSessionTurn(deps.db, workspaceId, callerTurnId);
680
+ if (turn && !isHumanDirectedTurn(turn)) {
681
+ throw new Error(
682
+ "This session was paused by the user. An internal turn cannot resume or replace the goal — only a new human/API prompt can. Report your findings and do not call goal_set.",
683
+ );
417
684
  }
418
685
  }
419
686
 
@@ -424,127 +691,287 @@ function registerGoalTools(
424
691
  sessionId: string,
425
692
  json: (value: unknown) => { content: Array<{ type: "text"; text: string }> },
426
693
  ): void {
427
- server.registerTool("goal_set", {
428
- description: "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
429
- inputSchema: {
430
- text: z4.string().min(1),
431
- successCriteria: z4.string().min(1).optional(),
432
- maxAutoContinuations: z4.number().int().positive().optional(),
694
+ server.registerTool(
695
+ "goal_set",
696
+ {
697
+ description:
698
+ "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
699
+ inputSchema: {
700
+ text: z4.string().min(1),
701
+ successCriteria: z4.string().min(1).optional(),
702
+ maxAutoContinuations: z4.number().int().positive().optional(),
703
+ },
433
704
  },
434
- }, async ({ text, successCriteria, maxAutoContinuations }) => {
435
- await requireSession(deps.db, grant.workspaceId, sessionId);
436
- const { goal, replaced } = await upsertSessionGoal(deps.db, {
437
- accountId: grant.accountId,
438
- workspaceId: grant.workspaceId,
439
- sessionId,
440
- text,
441
- successCriteria: successCriteria ?? null,
442
- maxAutoContinuations: maxAutoContinuations ?? null,
443
- createdBy: "agent",
444
- });
445
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
446
- type: "goal.set",
447
- payload: {
448
- goalId: goal.id,
449
- text: goal.text,
450
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
451
- version: goal.version,
452
- actor: "agent",
453
- replaced,
454
- },
455
- }]);
456
- return json(goal);
457
- });
705
+ async ({ text, successCriteria, maxAutoContinuations }) => {
706
+ await requireSession(deps.db, grant.workspaceId, sessionId);
707
+ const callerTurnId =
708
+ typeof grant.metadata?.["turnId"] === "string"
709
+ ? (grant.metadata["turnId"] as string)
710
+ : null;
711
+ await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
712
+ const { goal, replaced } = await upsertSessionGoal(deps.db, {
713
+ accountId: grant.accountId,
714
+ workspaceId: grant.workspaceId,
715
+ sessionId,
716
+ text,
717
+ successCriteria: successCriteria ?? null,
718
+ maxAutoContinuations: maxAutoContinuations ?? null,
719
+ createdBy: "agent",
720
+ });
721
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
722
+ {
723
+ type: "goal.set",
724
+ payload: {
725
+ goalId: goal.id,
726
+ text: goal.text,
727
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
728
+ version: goal.version,
729
+ actor: "agent",
730
+ replaced,
731
+ },
732
+ },
733
+ ]);
734
+ return json(goal);
735
+ },
736
+ );
458
737
 
459
- server.registerTool("goal_update", {
460
- description: "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
461
- inputSchema: {
462
- text: z4.string().min(1).optional(),
463
- successCriteria: z4.string().min(1).optional(),
464
- progressNote: z4.string().min(1).optional(),
738
+ server.registerTool(
739
+ "goal_update",
740
+ {
741
+ description:
742
+ "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
743
+ inputSchema: {
744
+ text: z4.string().min(1).optional(),
745
+ successCriteria: z4.string().min(1).optional(),
746
+ progressNote: z4.string().min(1).optional(),
747
+ },
465
748
  },
466
- }, async ({ text, successCriteria, progressNote }) => {
467
- await requireSession(deps.db, grant.workspaceId, sessionId);
468
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
469
- if (!existing) {
470
- throw new Error("this session has no goal; use goal_set first");
471
- }
472
- if (existing.status === "completed") {
473
- throw new Error("session goal is completed; use goal_set to start a new goal");
474
- }
475
- const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
476
- ...(text !== undefined ? { text } : {}),
477
- ...(successCriteria !== undefined ? { successCriteria } : {}),
478
- });
479
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
480
- type: "goal.updated",
481
- payload: {
482
- goalId: goal.id,
483
- text: goal.text,
484
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
485
- ...(progressNote ? { progressNote } : {}),
486
- version: goal.version,
487
- actor: "agent",
488
- },
489
- }]);
490
- return json(goal);
491
- });
749
+ async ({ text, successCriteria, progressNote }) => {
750
+ await requireSession(deps.db, grant.workspaceId, sessionId);
751
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
752
+ if (!existing) {
753
+ throw new Error("this session has no goal; use goal_set first");
754
+ }
755
+ if (existing.status === "completed") {
756
+ throw new Error("session goal is completed; use goal_set to start a new goal");
757
+ }
758
+ const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
759
+ ...(text !== undefined ? { text } : {}),
760
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
761
+ });
762
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
763
+ {
764
+ type: "goal.updated",
765
+ payload: {
766
+ goalId: goal.id,
767
+ text: goal.text,
768
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
769
+ ...(progressNote ? { progressNote } : {}),
770
+ version: goal.version,
771
+ actor: "agent",
772
+ },
773
+ },
774
+ ]);
775
+ return json(goal);
776
+ },
777
+ );
492
778
 
493
- server.registerTool("goal_complete", {
494
- description: "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). This is the explicit stop signal: no further continuation turns are synthesized.",
495
- inputSchema: { evidence: z4.string().min(1) },
496
- }, async ({ evidence }) => {
497
- await requireSession(deps.db, grant.workspaceId, sessionId);
498
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
499
- if (!existing) {
500
- throw new Error("this session has no goal; use goal_set first");
501
- }
502
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
503
- status: "completed",
504
- evidence,
505
- });
506
- if (changed) {
507
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
508
- type: "goal.completed",
509
- payload: { goalId: goal.id, evidence, version: goal.version },
510
- }]);
511
- }
512
- return json(goal);
513
- });
779
+ server.registerTool(
780
+ "goal_complete",
781
+ {
782
+ description:
783
+ "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). Completion prevents further continuation turns.",
784
+ inputSchema: { evidence: z4.string().min(1) },
785
+ },
786
+ async ({ evidence }) => {
787
+ await requireSession(deps.db, grant.workspaceId, sessionId);
788
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
789
+ if (!existing) {
790
+ throw new Error("this session has no goal; use goal_set first");
791
+ }
792
+ const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
793
+ status: "completed",
794
+ evidence,
795
+ });
796
+ if (changed) {
797
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
798
+ {
799
+ type: "goal.completed",
800
+ payload: { goalId: goal.id, evidence, version: goal.version },
801
+ },
802
+ ]);
803
+ }
804
+ return json(goal);
805
+ },
806
+ );
514
807
 
515
- server.registerTool("goal_pause", {
516
- description: "Pause the session goal with a rationale (blocked, not productive, needs human input). This is the explicit stop signal: no further continuation turns are synthesized until the goal is resumed or replaced.",
517
- inputSchema: { rationale: z4.string().min(1) },
518
- }, async ({ rationale }) => {
519
- await requireSession(deps.db, grant.workspaceId, sessionId);
520
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
521
- if (!existing) {
522
- throw new Error("this session has no goal; use goal_set first");
523
- }
524
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
525
- status: "paused",
526
- rationale,
527
- pausedReason: "agent",
528
- });
529
- if (changed) {
530
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
531
- type: "goal.paused",
532
- payload: {
533
- goalId: goal.id,
534
- actor: "agent",
535
- reason: "agent",
536
- rationale,
537
- autoContinuations: goal.autoContinuations,
538
- noProgressStreak: goal.noProgressStreak,
539
- },
540
- }]);
541
- }
542
- return json(goal);
543
- });
808
+ server.registerTool(
809
+ "goal_pause",
810
+ {
811
+ description:
812
+ "Pause the session goal with a rationale (blocked, not productive, needs human input). No further continuation turns are synthesized until the goal is resumed or replaced.",
813
+ inputSchema: { rationale: z4.string().min(1) },
814
+ },
815
+ async ({ rationale }) => {
816
+ await requireSession(deps.db, grant.workspaceId, sessionId);
817
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
818
+ if (!existing) {
819
+ throw new Error("this session has no goal; use goal_set first");
820
+ }
821
+ const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
822
+ status: "paused",
823
+ rationale,
824
+ pausedReason: "agent",
825
+ });
826
+ if (changed) {
827
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
828
+ {
829
+ type: "goal.paused",
830
+ payload: {
831
+ goalId: goal.id,
832
+ actor: "agent",
833
+ reason: "agent",
834
+ rationale,
835
+ autoContinuations: goal.autoContinuations,
836
+ noProgressStreak: goal.noProgressStreak,
837
+ },
838
+ },
839
+ ]);
840
+ }
841
+ return json(goal);
842
+ },
843
+ );
544
844
  }
545
845
 
546
846
  type JsonResult = (value: unknown) => { content: Array<{ type: "text"; text: string }> };
547
847
 
848
+ const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
849
+
850
+ function memoryPreview(text: string): string {
851
+ const normalized = text.replace(/\s+/g, " ").trim();
852
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
853
+ }
854
+
855
+ function registerMemoryTools(
856
+ server: McpServer,
857
+ deps: ApiRouteDeps,
858
+ grant: AccessGrant,
859
+ sessionId: string,
860
+ json: JsonResult,
861
+ ): void {
862
+ server.registerTool(
863
+ "memory_search",
864
+ {
865
+ description: MEMORY_SEARCH_TOOL_DESCRIPTION,
866
+ inputSchema: {
867
+ query: z4.string().min(1),
868
+ kind: MemoryKindSchema.optional(),
869
+ limit: z4.number().int().positive().max(20).optional(),
870
+ },
871
+ },
872
+ async ({ query, kind, limit }) =>
873
+ json({
874
+ results: await searchWorkspaceMemories(
875
+ deps.db,
876
+ grant.workspaceId,
877
+ {
878
+ query,
879
+ ...(kind ? { kind } : {}),
880
+ ...(limit ? { limit } : {}),
881
+ },
882
+ deps.getDocumentServices().embedder,
883
+ ),
884
+ }),
885
+ );
886
+
887
+ server.registerTool(
888
+ "memory_save",
889
+ {
890
+ description: MEMORY_SAVE_TOOL_DESCRIPTION,
891
+ inputSchema: {
892
+ text: z4.string().min(1),
893
+ kind: MemoryKindSchema,
894
+ confidence: z4.number().min(0).max(1).optional(),
895
+ replaces_id: z4.string().min(1).optional(),
896
+ },
897
+ },
898
+ async ({ text, kind, confidence, replaces_id }) => {
899
+ const result = await saveWorkspaceMemory(
900
+ deps.db,
901
+ {
902
+ accountId: grant.accountId,
903
+ workspaceId: grant.workspaceId,
904
+ sessionId,
905
+ text,
906
+ kind,
907
+ ...(confidence !== undefined ? { confidence } : {}),
908
+ ...(replaces_id ? { replacesId: replaces_id } : {}),
909
+ origin: "agent",
910
+ },
911
+ deps.getDocumentServices().embedder,
912
+ );
913
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
914
+ {
915
+ type: "memory.saved",
916
+ payload: {
917
+ memoryId: result.memory.id,
918
+ kind: result.memory.kind,
919
+ preview: memoryPreview(result.memory.text),
920
+ deduped: result.deduped,
921
+ ...(result.superseded ? { supersededMemoryId: result.superseded.id } : {}),
922
+ },
923
+ },
924
+ ]);
925
+ return json(result);
926
+ },
927
+ );
928
+
929
+ server.registerTool(
930
+ "memory_correct",
931
+ {
932
+ description: MEMORY_CORRECT_TOOL_DESCRIPTION,
933
+ inputSchema: {
934
+ id: z4.string().min(1),
935
+ reason: z4.string().min(1).optional(),
936
+ replacement_text: z4.string().min(1).optional(),
937
+ },
938
+ },
939
+ async ({ id, reason, replacement_text }) => {
940
+ const result = await correctWorkspaceMemory(
941
+ deps.db,
942
+ {
943
+ accountId: grant.accountId,
944
+ workspaceId: grant.workspaceId,
945
+ sessionId,
946
+ id,
947
+ ...(reason ? { reason } : {}),
948
+ ...(replacement_text ? { replacementText: replacement_text } : {}),
949
+ },
950
+ deps.getDocumentServices().embedder,
951
+ );
952
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
953
+ {
954
+ type: "memory.corrected",
955
+ payload: {
956
+ memoryId: result.memory.id,
957
+ kind: result.memory.kind,
958
+ preview: memoryPreview(result.memory.text),
959
+ action: result.action,
960
+ ...(reason ? { reason: memoryPreview(reason) } : {}),
961
+ ...(result.replacement
962
+ ? {
963
+ replacementMemoryId: result.replacement.id,
964
+ replacementPreview: memoryPreview(result.replacement.text),
965
+ }
966
+ : {}),
967
+ },
968
+ },
969
+ ]);
970
+ return json(result);
971
+ },
972
+ );
973
+ }
974
+
548
975
  // Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
549
976
  // session's active-sandbox pointer + reach the workspace's enrolled machines),
550
977
  // registered only with the worker-signed sessionId claim + the selfhosted flag.
@@ -572,300 +999,672 @@ function registerFleetTools(
572
999
  sessionId,
573
1000
  });
574
1001
 
575
- server.registerTool("sandboxes_list", {
576
- description:
577
- "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
578
- inputSchema: {},
579
- }, async () => json(await listFleet(services, await fleetContext())));
580
-
581
- server.registerTool("sandbox_attach", {
582
- description:
583
- "Attach this session to a sandbox (make it the active sandbox the agent's next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or \"session\"/\"default\" for this session's own box.",
584
- inputSchema: { target: z4.string().min(1) },
585
- }, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
586
-
587
- server.registerTool("sandbox_swap", {
588
- description:
589
- "Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or \"session\"/\"default\" to swap back to this session's own box.",
590
- inputSchema: { target: z4.string().min(1) },
591
- }, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
592
-
593
- server.registerTool("run_on", {
594
- description:
595
- "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
596
- inputSchema: {
597
- target: z4.string().min(1),
598
- op: z4.discriminatedUnion("kind", [
599
- z4.object({ kind: z4.literal("exec"), cmd: z4.string().min(1), workdir: z4.string().optional() }),
600
- z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
601
- z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
602
- ]),
1002
+ server.registerTool(
1003
+ "sandboxes_list",
1004
+ {
1005
+ description:
1006
+ "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
1007
+ inputSchema: {},
1008
+ },
1009
+ async () => json(await listFleet(services, await fleetContext())),
1010
+ );
1011
+
1012
+ server.registerTool(
1013
+ "sandbox_attach",
1014
+ {
1015
+ description:
1016
+ 'Attach this session to a sandbox (make it the active sandbox the agent\'s next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or "session"/"default" for this session\'s own box.',
1017
+ inputSchema: { target: z4.string().min(1) },
1018
+ },
1019
+ async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
1020
+ );
1021
+
1022
+ server.registerTool(
1023
+ "sandbox_swap",
1024
+ {
1025
+ description:
1026
+ 'Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or "session"/"default" to swap back to this session\'s own box.',
1027
+ inputSchema: { target: z4.string().min(1) },
603
1028
  },
604
- }, async ({ target, op }) => json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)));
605
-
606
- server.registerTool("sandbox_provision", {
607
- description:
608
- "Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record (its box materializes on first swap).",
609
- inputSchema: {
610
- kind: z4.enum(["selfhosted", "modal"]),
611
- name: z4.string().min(1).max(120).optional(),
1029
+ async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
1030
+ );
1031
+
1032
+ server.registerTool(
1033
+ "run_on",
1034
+ {
1035
+ description:
1036
+ "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
1037
+ inputSchema: {
1038
+ target: z4.string().min(1),
1039
+ op: z4.discriminatedUnion("kind", [
1040
+ z4.object({
1041
+ kind: z4.literal("exec"),
1042
+ cmd: z4.string().min(1),
1043
+ workdir: z4.string().optional(),
1044
+ }),
1045
+ z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
1046
+ z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
1047
+ ]),
1048
+ },
612
1049
  },
613
- }, async ({ kind, name }) => json(await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) })));
1050
+ async ({ target, op }) =>
1051
+ json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)),
1052
+ );
1053
+
1054
+ server.registerTool(
1055
+ "sandbox_provision",
1056
+ {
1057
+ description:
1058
+ "Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
1059
+ inputSchema: {
1060
+ kind: z4.enum(["selfhosted", "modal"]),
1061
+ name: z4.string().min(1).max(120).optional(),
1062
+ },
1063
+ },
1064
+ async ({ kind, name }) =>
1065
+ json(
1066
+ await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) }),
1067
+ ),
1068
+ );
1069
+ }
1070
+
1071
+ async function beginMcpRigVerificationAttempt(
1072
+ deps: ApiRouteDeps,
1073
+ workspaceId: string,
1074
+ changeId: string,
1075
+ ) {
1076
+ try {
1077
+ return await beginRigChangeVerificationAttempt(deps.db, workspaceId, changeId, {
1078
+ startedAt: new Date().toISOString(),
1079
+ });
1080
+ } catch (error) {
1081
+ if (
1082
+ error instanceof RigChangeAlreadyVerifyingError ||
1083
+ error instanceof RigChangeTransitionError
1084
+ ) {
1085
+ throw new Error(error.message, { cause: error });
1086
+ }
1087
+ throw error;
1088
+ }
1089
+ }
1090
+
1091
+ function verificationAttempt(change: {
1092
+ verification?: Record<string, unknown> | null;
1093
+ }): number | string {
1094
+ return typeof change.verification?.attempt === "number"
1095
+ ? change.verification.attempt
1096
+ : crypto.randomUUID();
614
1097
  }
615
1098
 
616
- // Workspace orchestration for manager-style agents: sessions are listed,
617
- // inspected, spawned, and steered with the same domain functions the REST
618
- // routes use, so limits, validation, and usage metering cannot drift.
1099
+ function registerRigTools(
1100
+ server: McpServer,
1101
+ deps: ApiRouteDeps,
1102
+ grant: AccessGrant,
1103
+ can: (permission: Permission) => boolean,
1104
+ sessionId: string | null,
1105
+ json: JsonResult,
1106
+ ): void {
1107
+ if (can("rigs:use")) {
1108
+ server.registerTool(
1109
+ "rig_list",
1110
+ {
1111
+ description: "List workspace rigs and their active versions.",
1112
+ inputSchema: {},
1113
+ },
1114
+ async () => json({ rigs: await listRigs(deps.db, grant.workspaceId) }),
1115
+ );
1116
+
1117
+ server.registerTool(
1118
+ "rig_get",
1119
+ {
1120
+ description: "Get a rig, its versions, and recent changes.",
1121
+ inputSchema: {
1122
+ rigId: z4.string().uuid(),
1123
+ changeLimit: z4.number().int().positive().optional(),
1124
+ },
1125
+ },
1126
+ async ({ rigId, changeLimit }) => {
1127
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1128
+ return json({
1129
+ rig,
1130
+ versions: await listRigVersionsForApi({ db: deps.db }, grant.workspaceId, rig.id),
1131
+ changes: await listRigChangesForApi(
1132
+ { db: deps.db },
1133
+ grant.workspaceId,
1134
+ rig.id,
1135
+ boundedMcpLimit(changeLimit),
1136
+ ),
1137
+ });
1138
+ },
1139
+ );
1140
+
1141
+ server.registerTool(
1142
+ "rig_propose_change",
1143
+ {
1144
+ description:
1145
+ "Propose an additive rig setup command for clean verification. Use the exact command that already worked in this sandbox.",
1146
+ inputSchema: {
1147
+ rigId: z4.string().uuid(),
1148
+ command: z4.string().min(1).max(8192),
1149
+ note: z4.string().max(2000).optional(),
1150
+ },
1151
+ },
1152
+ async ({ rigId, command, note }) => {
1153
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1154
+ const change = await proposeRigChangeForApi(
1155
+ { db: deps.db },
1156
+ grant,
1157
+ rig,
1158
+ {
1159
+ kind: "setup_append",
1160
+ payload: { command, ...(note ? { note } : {}) },
1161
+ },
1162
+ sessionId ? { proposedBy: `session:${sessionId}` } : {},
1163
+ );
1164
+ const verifying = await beginMcpRigVerificationAttempt(deps, grant.workspaceId, change.id);
1165
+ await deps.workflowClient.startRigVerification({
1166
+ workspaceId: grant.workspaceId,
1167
+ changeId: change.id,
1168
+ workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
1169
+ });
1170
+ return json({ change: verifying, verificationStarted: true });
1171
+ },
1172
+ );
1173
+
1174
+ server.registerTool(
1175
+ "rig_verify",
1176
+ {
1177
+ description:
1178
+ "Trigger rig verification. Pass changeId for a proposed change, or omit it to re-verify the active version's checks.",
1179
+ inputSchema: {
1180
+ rigId: z4.string().uuid(),
1181
+ changeId: z4.string().uuid().optional(),
1182
+ },
1183
+ },
1184
+ async ({ rigId, changeId }) => {
1185
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1186
+ if (changeId) {
1187
+ const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
1188
+ const verifying = await beginMcpRigVerificationAttempt(
1189
+ deps,
1190
+ grant.workspaceId,
1191
+ change.id,
1192
+ );
1193
+ await deps.workflowClient.startRigVerification({
1194
+ workspaceId: grant.workspaceId,
1195
+ changeId: change.id,
1196
+ workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
1197
+ });
1198
+ return json({ ok: true, changeId: change.id });
1199
+ }
1200
+ if (!rig.activeVersion) {
1201
+ throw new Error("rig has no active version");
1202
+ }
1203
+ await deps.workflowClient.startRigVerification({
1204
+ workspaceId: grant.workspaceId,
1205
+ versionId: rig.activeVersion.id,
1206
+ workflowId: `rig-verification-version-${rig.activeVersion.id}-${crypto.randomUUID()}`,
1207
+ });
1208
+ return json({ ok: true, versionId: rig.activeVersion.id });
1209
+ },
1210
+ );
1211
+ }
1212
+
1213
+ if (can("rigs:manage")) {
1214
+ server.registerTool(
1215
+ "rig_promote",
1216
+ {
1217
+ description:
1218
+ "Promote a verified definition_edit rig change to a new active immutable version. Requires rigs:manage.",
1219
+ inputSchema: {
1220
+ rigId: z4.string().uuid(),
1221
+ changeId: z4.string().uuid(),
1222
+ },
1223
+ },
1224
+ async ({ rigId, changeId }) => {
1225
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1226
+ const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
1227
+ return json(
1228
+ await promoteVerifiedDefinitionEditChangeForApi({ db: deps.db }, grant, rig, change),
1229
+ );
1230
+ },
1231
+ );
1232
+ }
1233
+ }
1234
+
1235
+ // Workspace orchestration for manager-style agents. Session-authenticated
1236
+ // workers communicate through the typed internal-update plane; only a
1237
+ // sessionless operator can append a visible prompt through this surface.
619
1238
  function registerWorkspaceOrchestrationTools(
620
1239
  server: McpServer,
621
1240
  deps: ApiRouteDeps,
622
1241
  grant: AccessGrant,
623
1242
  can: (permission: Permission) => boolean,
1243
+ callerSessionId: string | null,
624
1244
  json: JsonResult,
625
1245
  ): void {
626
1246
  if (can("sessions:read")) {
627
- server.registerTool("sessions_list", {
628
- description: "List sessions in this workspace, newest first.",
629
- inputSchema: { limit: z4.number().int().positive().optional() },
630
- }, async ({ limit }) => json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
631
-
632
- server.registerTool("session_get", {
633
- description: "Get one session: status, goal-bearing metadata, resources, tools, and environment attachment (names/ids only, never variable values). Unbounded agent-set fields (metadata, initial message) are clamped so monitoring another session cannot flood this context.",
634
- inputSchema: { sessionId: z4.string().uuid() },
635
- }, async ({ sessionId }) => {
636
- const session = await getSession(deps.db, grant.workspaceId, sessionId);
637
- if (!session) {
638
- throw new Error("session not found");
639
- }
640
- return json(capSessionDetail(session));
641
- });
1247
+ server.registerTool(
1248
+ "sessions_list",
1249
+ {
1250
+ description: "List sessions in this workspace, newest first.",
1251
+ inputSchema: { limit: z4.number().int().positive().optional() },
1252
+ },
1253
+ async ({ limit }) =>
1254
+ json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }),
1255
+ );
642
1256
 
643
- server.registerTool("session_events", {
644
- description: "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.",
645
- inputSchema: {
646
- sessionId: z4.string().uuid(),
647
- after: z4.number().int().nonnegative().optional(),
648
- limit: z4.number().int().positive().optional(),
1257
+ server.registerTool(
1258
+ "session_get",
1259
+ {
1260
+ description:
1261
+ "Get another session you are managing: status, goal-bearing metadata, resources, tools, and variableSet attachment (names/ids only, never variable values). Do not call this with your own current session id to reconstruct context; your model-facing conversation history and persistent setting state are already supplied directly. Unbounded agent-set fields are clamped so monitoring another session cannot flood this context.",
1262
+ inputSchema: { sessionId: z4.string().uuid() },
649
1263
  },
650
- }, async ({ sessionId, after, limit }) => {
651
- await requireSession(deps.db, grant.workspaceId, sessionId);
652
- const events = await listSessionEvents(deps.db, grant.workspaceId, sessionId, after ?? 0, boundedMcpLimit(limit));
653
- const capped = capEventPage(events);
654
- return json({
655
- events: capped.events,
656
- nextAfter: capped.nextAfter ?? after ?? 0,
657
- ...(capped.truncated ? { truncated: true } : {}),
658
- });
659
- });
1264
+ async ({ sessionId }) => {
1265
+ const session = await getSession(deps.db, grant.workspaceId, sessionId);
1266
+ if (!session) {
1267
+ throw new Error("session not found");
1268
+ }
1269
+ return json(capSessionDetail(session));
1270
+ },
1271
+ );
1272
+
1273
+ server.registerTool(
1274
+ "session_events",
1275
+ {
1276
+ description:
1277
+ "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.",
1278
+ inputSchema: {
1279
+ sessionId: z4.string().uuid(),
1280
+ after: z4.number().int().nonnegative().optional(),
1281
+ limit: z4.number().int().positive().optional(),
1282
+ },
1283
+ },
1284
+ async ({ sessionId, after, limit }) => {
1285
+ await requireSession(deps.db, grant.workspaceId, sessionId);
1286
+ const events = await listSessionEvents(
1287
+ deps.db,
1288
+ grant.workspaceId,
1289
+ sessionId,
1290
+ after ?? 0,
1291
+ boundedMcpLimit(limit),
1292
+ );
1293
+ const capped = capEventPage(events);
1294
+ return json({
1295
+ events: capped.events,
1296
+ nextAfter: capped.nextAfter ?? after ?? 0,
1297
+ ...(capped.truncated ? { truncated: true } : {}),
1298
+ });
1299
+ },
1300
+ );
660
1301
  }
661
1302
 
662
1303
  if (can("sessions:create")) {
663
- server.registerTool("session_create", {
664
- description: "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and workspace environment attachment. Environment attachment happens at creation only — it cannot be added to a running session — and requires the environments:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
665
- inputSchema: {
666
- initialMessage: z4.string().min(1),
667
- // Per-session agent persona/system instructions for the spawned worker
668
- // (a per-agent-type prompt). Delivered system-level, composed AFTER the
669
- // workspace persona; never shown in the worker's timeline. Trimmed,
670
- // non-empty, max 32768 chars (re-validated by the contracts schema).
671
- instructions: z4.string().min(1).max(32768).optional(),
672
- goal: z4.unknown().optional(),
673
- resources: z4.array(z4.unknown()).optional(),
674
- tools: z4.array(z4.unknown()).optional(),
675
- // Per-session third-party MCP servers. Credential header values are
676
- // accepted only at create and never appear in responses/events.
677
- mcpServers: z4.array(z4.unknown()).optional(),
678
- environmentId: z4.string().uuid().optional(),
679
- model: z4.string().min(1).optional(),
680
- reasoningEffort: z4.string().optional(),
681
- sandboxBackend: z4.string().optional(),
682
- // Create-time machine targeting: an enrolled sandbox id (from
683
- // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
684
- // pointer at creation so the FIRST turn lands on the chosen machine
685
- // (race-free). Ownership + liveness are validated in the domain via the
686
- // same path as sandbox_swap; an unowned/offline/unknown target 422s.
687
- targetSandboxId: z4.string().uuid().optional(),
688
- // The working directory (cwd) for a machine target: the path/cwd base the
689
- // spawned session's agent exec, terminal, and file dock run under. A
690
- // workspace_root-relative subdir or an absolute machine path. Only valid
691
- // WITH targetSandboxId (workingDir alone 422s); omitted workspace_root.
692
- workingDir: z4.string().optional(),
693
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
694
- // Workspace-scoped CREATE idempotency key: a retried session_create with
695
- // the same key returns the already-spawned worker instead of a duplicate.
696
- idempotencyKey: z4.string().min(1).max(200).optional(),
697
- // First-party MCP token permissions for the spawned session; every
698
- // permission must be held by this grant (validated in the domain).
699
- firstPartyMcpPermissions: z4.array(z4.string()).optional(),
700
- // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
701
- // creator's box — one filesystem/repo/desktop, N independent conversations;
702
- // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
703
- // repo set or a genuinely separate filesystem), or {groupId} (a sibling
704
- // session's `sandboxGroupId` from a prior session_create response) to join
705
- // that specific sibling's box.
706
- // Shared state must be compatible: a shared box requires the SAME image
707
- // (rejected at the lease layer, B3) and because the box's environment is
708
- // fixed at creation under the current mechanics — the SAME workspace
709
- // Environment. The domain layer is env-aware: an inherited default with a
710
- // different environmentId silently gets its OWN box (the spawn still works),
711
- // while an explicit shared/{groupId} with a mismatched environment 422s at
712
- // create. When the Environment is eventually evicted from the box manifest
713
- // (per-exec, like the git token), the env check dissolves on its own.
714
- // The description below is what the AGENT sees (this comment is invisible to
715
- // it); keep the two in sync.
716
- sandbox: z4.union([
717
- z4.literal("shared"),
718
- z4.literal("new"),
719
- z4.object({ groupId: z4.string().uuid() }),
720
- ]).describe(
721
- "Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT environment than the creator's box, the platform automatically gives it its own box (the box environment is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same environment (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
722
- ).optional(),
723
- // The parent (manager) session is auto-inferred from the caller's
724
- // worker-signed sessionId claim, so a spawned worker's completion wakes
725
- // its manager automatically. There is deliberately no caller-supplied
726
- // parent parameter: it would let a sessions:create grant target an
727
- // arbitrary session's wake channel without sessions:control on it.
728
- },
729
- }, async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)));
1304
+ server.registerTool(
1305
+ "session_create",
1306
+ {
1307
+ description:
1308
+ "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and variable set attachment. VariableSet attachment happens at creation only — it cannot be added to a running session — and requires the variable-sets:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
1309
+ inputSchema: {
1310
+ initialMessage: z4.string().min(1),
1311
+ // Per-session agent persona/system instructions for the spawned worker
1312
+ // (a per-agent-type prompt). Delivered system-level, composed AFTER the
1313
+ // workspace persona; never shown in the worker's timeline. Trimmed,
1314
+ // non-empty, max 32768 chars (re-validated by the contracts schema).
1315
+ instructions: z4.string().min(1).max(32768).optional(),
1316
+ goal: z4.unknown().optional(),
1317
+ resources: z4.array(z4.unknown()).optional(),
1318
+ tools: z4.array(z4.unknown()).optional(),
1319
+ // Per-session third-party MCP servers. Credential header values are
1320
+ // accepted only at create and never appear in responses/events.
1321
+ mcpServers: z4.array(z4.unknown()).optional(),
1322
+ variableSetId: z4.string().uuid().optional(),
1323
+ // Deprecated alias of variableSetId (rename back-compat); declared so MCP
1324
+ // validation doesn't strip it before createSessionForRequest maps it.
1325
+ environmentId: z4.string().uuid().optional(),
1326
+ // Bind the spawned session to a rig (freezes its active version);
1327
+ // declared so MCP validation doesn't strip it before the domain reads it.
1328
+ rigId: z4.string().uuid().optional(),
1329
+ model: z4.string().min(1).optional(),
1330
+ reasoningEffort: z4.string().optional(),
1331
+ sandboxBackend: z4.string().optional(),
1332
+ // Create-time machine targeting: an enrolled sandbox id (from
1333
+ // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
1334
+ // pointer at creation so the FIRST turn lands on the chosen machine
1335
+ // (race-free). Ownership + liveness are validated in the domain via the
1336
+ // same path as sandbox_swap; an unowned/offline/unknown target 422s.
1337
+ targetSandboxId: z4.string().uuid().optional(),
1338
+ // The working directory (cwd) for a machine target: the path/cwd base the
1339
+ // spawned session's agent exec, terminal, and file dock run under. A
1340
+ // workspace_root-relative subdir or an absolute machine path. Only valid
1341
+ // WITH targetSandboxId (workingDir alone 422s); omitted workspace_root.
1342
+ workingDir: z4.string().optional(),
1343
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
1344
+ // Workspace-scoped CREATE idempotency key: a retried session_create with
1345
+ // the same key returns the already-spawned worker instead of a duplicate.
1346
+ idempotencyKey: z4.string().min(1).max(200).optional(),
1347
+ // First-party MCP token permissions for the spawned session; every
1348
+ // permission must be held by this grant (validated in the domain).
1349
+ firstPartyMcpPermissions: z4.array(z4.string()).optional(),
1350
+ // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
1351
+ // creator's box one filesystem/repo/desktop, N independent conversations;
1352
+ // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
1353
+ // repo set or a genuinely separate filesystem), or {groupId} (a sibling
1354
+ // session's `sandboxGroupId` from a prior session_create response) to join
1355
+ // that specific sibling's box.
1356
+ // Shared state must be compatible: a shared box requires the SAME image
1357
+ // (rejected at the lease layer, B3) and — because the box's variable set is
1358
+ // fixed at creation under the current mechanics — the SAME workspace
1359
+ // VariableSet. The domain layer is env-aware: an inherited default with a
1360
+ // different variableSetId silently gets its OWN box (the spawn still works),
1361
+ // while an explicit shared/{groupId} with a mismatched variableSet 422s at
1362
+ // create. When the VariableSet is eventually evicted from the box manifest
1363
+ // (per-exec, like the git token), the env check dissolves on its own.
1364
+ // The description below is what the AGENT sees (this comment is invisible to
1365
+ // it); keep the two in sync.
1366
+ sandbox: z4
1367
+ .union([
1368
+ z4.literal("shared"),
1369
+ z4.literal("new"),
1370
+ z4.object({ groupId: z4.string().uuid() }),
1371
+ ])
1372
+ .describe(
1373
+ "Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT variableSet than the creator's box, the platform automatically gives it its own box (the box variable set is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same variableSet (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
1374
+ )
1375
+ .optional(),
1376
+ // The parent (manager) session is auto-inferred from the caller's
1377
+ // worker-signed sessionId claim, so a spawned worker's completion wakes
1378
+ // its manager automatically. There is deliberately no caller-supplied
1379
+ // parent parameter: it would let a sessions:create grant target an
1380
+ // arbitrary session's wake channel without sessions:control on it.
1381
+ },
1382
+ },
1383
+ async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)),
1384
+ );
730
1385
  }
731
1386
 
732
1387
  if (can("sessions:control")) {
733
- server.registerTool("session_send_message", {
734
- description: "Post a user message into an existing session; the session queues a turn and resumes if idle.",
735
- inputSchema: {
736
- sessionId: z4.string().uuid(),
737
- text: z4.string().min(1),
738
- // Header-value rotation only. URL/name/tool settings are immutable
739
- // after create; core enforces mcp_servers:attach on this field.
740
- mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
1388
+ server.registerTool(
1389
+ "session_send_message",
1390
+ {
1391
+ description:
1392
+ "Send information to another session. From an OpenGeni worker this becomes a coalescible internal update, never a visible prompt-queue row; pending updates are delivered together on the target's next inference. A sessionless operator call appends one visible prompt.",
1393
+ inputSchema: {
1394
+ sessionId: z4.string().uuid(),
1395
+ text: z4.string().min(1),
1396
+ // Header-value rotation only. URL/name/tool settings are immutable
1397
+ // after create; core enforces mcp_servers:attach on this field.
1398
+ mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
1399
+ },
741
1400
  },
742
- }, async ({ sessionId, text, mcpCredentialUpdates }) => {
743
- const { accepted, turn } = await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
744
- text,
745
- toolsProvided: false,
746
- mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) => SessionMcpCredentialUpdateInput.parse(update)),
747
- });
748
- return json({ event: accepted, turnId: turn.id });
749
- });
1401
+ async ({ sessionId: targetSessionId, text, mcpCredentialUpdates }) => {
1402
+ if (callerSessionId !== null) {
1403
+ if ((mcpCredentialUpdates?.length ?? 0) > 0) {
1404
+ throw new Error("internal session updates cannot change MCP credentials");
1405
+ }
1406
+ const result = await addSessionSystemUpdate(deps.db, {
1407
+ accountId: grant.accountId,
1408
+ workspaceId: grant.workspaceId,
1409
+ sessionId: targetSessionId,
1410
+ kind: "runtime_notice",
1411
+ classification: "info",
1412
+ sourceId: callerSessionId,
1413
+ dedupeKey: `session-message:${callerSessionId}:${crypto.randomUUID()}`,
1414
+ summary: text,
1415
+ payload: { text },
1416
+ lineage: { sourceSessionId: callerSessionId, targetSessionId },
1417
+ });
1418
+ if (result.reason === "session_cancelled") {
1419
+ return json({ delivered: false, reason: result.reason });
1420
+ }
1421
+ if (result.added && result.events.length > 0) {
1422
+ await deps.bus.publish(grant.workspaceId, targetSessionId, result.events);
1423
+ }
1424
+ if (result.shouldWake) {
1425
+ if (result.workflowWakeRevision === null) {
1426
+ throw new Error("Internal update has no workflow wake revision");
1427
+ }
1428
+ await deps.workflowClient.wakeSessionWorkflow({
1429
+ accountId: grant.accountId,
1430
+ workspaceId: grant.workspaceId,
1431
+ sessionId: targetSessionId,
1432
+ workflowId: result.temporalWorkflowId ?? workflowIdForSession(targetSessionId),
1433
+ wakeRevision: result.workflowWakeRevision,
1434
+ });
1435
+ }
1436
+ return json({
1437
+ delivered: true,
1438
+ updateId: result.update.id,
1439
+ delivery: "coalesced_internal_update",
1440
+ });
1441
+ }
1442
+ const { accepted, turn } = await acceptSessionUserMessage(
1443
+ deps,
1444
+ grant,
1445
+ grant.workspaceId,
1446
+ targetSessionId,
1447
+ {
1448
+ text,
1449
+ toolsProvided: false,
1450
+ delivery: "queue",
1451
+ origin: "operator",
1452
+ mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) =>
1453
+ SessionMcpCredentialUpdateInput.parse(update),
1454
+ ),
1455
+ },
1456
+ );
1457
+ return json({ event: accepted, turnId: turn.id });
1458
+ },
1459
+ );
750
1460
 
751
- server.registerTool("session_interrupt", {
752
- description:
753
- "Interrupt a session in this workspace. mode='stop' (default) cancels the current turn AND pauses the session's active goal so it halts. mode='steer' cancels the current turn WITHOUT pausing the goal, so the session picks up its next queued turn (or, if nothing is queued, continues toward its active goal) — pair it with a preceding session_send_message to redirect a running session. Works whether the target is mid-turn or idle.",
754
- inputSchema: {
755
- sessionId: z4.string().uuid(),
756
- mode: z4.enum(["stop", "steer"]).optional(),
1461
+ server.registerTool(
1462
+ "session_pause",
1463
+ {
1464
+ description: "Pause this session. Waiting prompts stay saved and inert until Resume.",
1465
+ inputSchema: {
1466
+ sessionId: z4.string().uuid(),
1467
+ },
757
1468
  },
758
- }, async ({ sessionId, mode }) => {
759
- await requireSession(deps.db, grant.workspaceId, sessionId);
760
- const appended = await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
761
- type: "user.interrupt",
762
- payload: mode === "steer" ? { reason: "steer" } : {},
763
- }]);
764
- const accepted = appended[0];
765
- if (!accepted) {
766
- throw new Error("failed to append interrupt event");
767
- }
768
- await deps.workflowClient.signalInterrupt({
769
- accountId: grant.accountId,
770
- workspaceId: grant.workspaceId,
771
- sessionId,
772
- eventId: accepted.id,
773
- workflowId: workflowIdForSession(sessionId),
774
- });
775
- return json({ event: accepted });
776
- });
1469
+ async ({ sessionId }) => {
1470
+ const controlled = await requestSessionControl(deps.db, {
1471
+ accountId: grant.accountId,
1472
+ workspaceId: grant.workspaceId,
1473
+ sessionId,
1474
+ actor: grant.subjectId,
1475
+ mode: "pause",
1476
+ reason: "mcp_pause",
1477
+ });
1478
+ await deps.bus.publish(grant.workspaceId, sessionId, controlled.events);
1479
+ if (controlled.shouldSignalControl) {
1480
+ if (controlled.workflowWakeRevision === null) {
1481
+ throw new Error("Session control has no workflow wake revision");
1482
+ }
1483
+ await deps.workflowClient.signalSessionControl({
1484
+ accountId: grant.accountId,
1485
+ workspaceId: grant.workspaceId,
1486
+ sessionId,
1487
+ eventId: controlled.event.id,
1488
+ workflowId: workflowIdForSession(sessionId),
1489
+ workflowWakeRevision: controlled.workflowWakeRevision,
1490
+ });
1491
+ }
1492
+ return json({
1493
+ event: controlled.event,
1494
+ controlState: controlled.controlState,
1495
+ controlGeneration: controlled.controlGeneration,
1496
+ });
1497
+ },
1498
+ );
777
1499
 
778
- server.registerTool("set_other_session_title", {
779
- description: "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
780
- inputSchema: {
781
- session_id: z4.string().uuid(),
782
- title: z4.string().min(1).max(200),
1500
+ server.registerTool(
1501
+ "set_other_session_title",
1502
+ {
1503
+ description:
1504
+ "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
1505
+ inputSchema: {
1506
+ session_id: z4.string().uuid(),
1507
+ title: z4.string().min(1).max(200),
1508
+ },
783
1509
  },
784
- }, async ({ session_id, title }) => {
785
- await requireSession(deps.db, grant.workspaceId, session_id);
786
- const result = await updateSessionTitle(deps, grant.workspaceId, session_id, title, "agent");
787
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
788
- });
1510
+ async ({ session_id, title }) => {
1511
+ await requireSession(deps.db, grant.workspaceId, session_id);
1512
+ const result = await updateSessionTitle(
1513
+ deps,
1514
+ grant.workspaceId,
1515
+ session_id,
1516
+ title,
1517
+ "agent",
1518
+ );
1519
+ return json({ ok: true, updated: result.updated, title: result.title ?? title });
1520
+ },
1521
+ );
789
1522
  }
790
1523
  }
791
1524
 
792
- // Environment management for manager-style agents. v1 deliberately accepts
1525
+ // VariableSet management for manager-style agents. v1 deliberately accepts
793
1526
  // variable VALUES in plain tool arguments: the calling model is trusted with
794
- // the secrets it is persisting (see docs/environments.md). Reads stay
1527
+ // the secrets it is persisting (see docs/variable-sets.md). Reads stay
795
1528
  // write-only — responses carry names and metadata, never values.
796
- function registerEnvironmentTools(
1529
+ function registerVariableSetTools(
797
1530
  server: McpServer,
798
1531
  deps: ApiRouteDeps,
799
1532
  grant: AccessGrant,
800
1533
  can: (permission: Permission) => boolean,
801
1534
  json: JsonResult,
802
1535
  ): void {
803
- if (can("environments:use")) {
804
- server.registerTool("environment_list", {
805
- description: "List workspace environments with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
806
- inputSchema: {},
807
- }, async () => json({ environments: await listWorkspaceEnvironments(deps.db, grant.workspaceId) }));
808
- }
809
-
810
- if (can("environments:manage")) {
811
- server.registerTool("environment_set_variable", {
812
- description: "Set or rotate one variable in a workspace environment. Target by environmentId, or by environmentName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the environment is attached to; it is never readable back through any API.",
813
- inputSchema: {
814
- environmentId: z4.string().uuid().optional(),
815
- environmentName: z4.string().min(1).optional(),
816
- name: z4.string().min(1),
817
- value: z4.string().min(1).max(32768),
818
- },
819
- }, async ({ environmentId, environmentName, name, value }) => {
820
- const key = requireEnvironmentEncryption(deps.settings);
821
- const parsedName = WorkspaceEnvironmentVariableName.safeParse(name);
822
- if (!parsedName.success) {
823
- throw new Error("environment variable names must match ^[A-Z][A-Z0-9_]*$");
824
- }
825
- assertAllowedEnvironmentVariableName(parsedName.data);
826
- if ((environmentId === undefined) === (environmentName === undefined)) {
827
- throw new Error("provide exactly one of environmentId or environmentName");
828
- }
829
- const trimmedEnvironmentName = environmentName?.trim();
830
- if (environmentName !== undefined && !trimmedEnvironmentName) {
831
- throw new Error("environment name is required");
832
- }
833
- let created = false;
834
- let environment = environmentId !== undefined
835
- ? await getWorkspaceEnvironment(deps.db, grant.workspaceId, environmentId)
836
- : await getWorkspaceEnvironmentByName(deps.db, grant.workspaceId, trimmedEnvironmentName!);
837
- if (!environment && environmentId !== undefined) {
838
- throw new Error("environment not found");
839
- }
840
- if (!environment) {
841
- if (await countWorkspaceEnvironments(deps.db, grant.workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
842
- throw new Error(`a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} environments`);
843
- }
844
- environment = await createWorkspaceEnvironment(deps.db, {
845
- accountId: grant.accountId,
846
- workspaceId: grant.workspaceId,
847
- name: trimmedEnvironmentName!,
848
- });
849
- created = true;
850
- await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.created", environmentId: environment.id });
851
- }
852
- const exists = environment.variables.some((variable) => variable.name === parsedName.data);
853
- if (!exists && environment.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
854
- throw new Error(`an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
1536
+ const registerListTool = (name: string, description: string): void => {
1537
+ server.registerTool(
1538
+ name,
1539
+ {
1540
+ description,
1541
+ inputSchema: {},
1542
+ },
1543
+ async () => {
1544
+ const variableSets = await listVariableSets(deps.db, grant.workspaceId);
1545
+ return json({ variableSets, environments: variableSets });
1546
+ },
1547
+ );
1548
+ };
1549
+ const setVariableHandler = async ({
1550
+ variableSetId,
1551
+ variableSetName,
1552
+ environmentId,
1553
+ environmentName,
1554
+ name,
1555
+ value,
1556
+ }: {
1557
+ variableSetId?: string | undefined;
1558
+ variableSetName?: string | undefined;
1559
+ environmentId?: string | undefined;
1560
+ environmentName?: string | undefined;
1561
+ name: string;
1562
+ value: string;
1563
+ }) => {
1564
+ const key = requireVariableSetEncryption(deps.settings);
1565
+ const parsedName = VariableSetVariableName.safeParse(name);
1566
+ if (!parsedName.success) {
1567
+ throw new Error("variable set/environment variable names must match ^[A-Z][A-Z0-9_]*$");
1568
+ }
1569
+ assertAllowedVariableSetVariableName(parsedName.data);
1570
+ const targetId = variableSetId ?? environmentId;
1571
+ const targetName = variableSetName ?? environmentName;
1572
+ if ((targetId === undefined) === (targetName === undefined)) {
1573
+ throw new Error(
1574
+ "provide exactly one of variableSetId or variableSetName; deprecated aliases must provide exactly one of environmentId or environmentName",
1575
+ );
1576
+ }
1577
+ const trimmedVariableSetName = targetName?.trim();
1578
+ if (targetName !== undefined && !trimmedVariableSetName) {
1579
+ throw new Error("variable set name is required");
1580
+ }
1581
+ let created = false;
1582
+ let variableSet =
1583
+ targetId !== undefined
1584
+ ? await getVariableSet(deps.db, grant.workspaceId, targetId)
1585
+ : await getVariableSetByName(deps.db, grant.workspaceId, trimmedVariableSetName!);
1586
+ if (!variableSet && targetId !== undefined) {
1587
+ throw new Error("variable set/environment not found");
1588
+ }
1589
+ if (!variableSet) {
1590
+ if ((await countVariableSets(deps.db, grant.workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
1591
+ throw new Error(
1592
+ `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
1593
+ );
855
1594
  }
856
- const metadata = await setWorkspaceEnvironmentVariable(deps.db, {
1595
+ variableSet = await createVariableSet(deps.db, {
857
1596
  accountId: grant.accountId,
858
1597
  workspaceId: grant.workspaceId,
859
- environmentId: environment.id,
860
- name: parsedName.data,
861
- valueEncrypted: encryptEnvironmentValue(key, value),
1598
+ name: trimmedVariableSetName!,
862
1599
  });
863
- await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: parsedName.data });
864
- return json({
865
- environment: { id: environment.id, name: environment.name, created },
866
- variable: metadata,
1600
+ created = true;
1601
+ await recordVariableSetAuditEvent(deps.db, {
1602
+ grant,
1603
+ action: "variable_set.created",
1604
+ variableSetId: variableSet.id,
867
1605
  });
1606
+ }
1607
+ const exists = variableSet.variables.some((variable) => variable.name === parsedName.data);
1608
+ if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
1609
+ throw new Error(`a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
1610
+ }
1611
+ const metadata = await setVariableSetVariable(deps.db, {
1612
+ accountId: grant.accountId,
1613
+ workspaceId: grant.workspaceId,
1614
+ variableSetId: variableSet.id,
1615
+ name: parsedName.data,
1616
+ valueEncrypted: encryptVariableSetValue(key, value),
1617
+ });
1618
+ await recordVariableSetAuditEvent(deps.db, {
1619
+ grant,
1620
+ action: "variable_set.variable.set",
1621
+ variableSetId: variableSet.id,
1622
+ variableName: parsedName.data,
868
1623
  });
1624
+ const responseVariableSet = { id: variableSet.id, name: variableSet.name, created };
1625
+ return json({
1626
+ variableSet: responseVariableSet,
1627
+ environment: responseVariableSet,
1628
+ variable: metadata,
1629
+ });
1630
+ };
1631
+ const registerSetTool = (name: string, description: string): void => {
1632
+ server.registerTool(
1633
+ name,
1634
+ {
1635
+ description,
1636
+ inputSchema: {
1637
+ variableSetId: z4.string().uuid().optional(),
1638
+ variableSetName: z4.string().min(1).optional(),
1639
+ environmentId: z4.string().uuid().optional(),
1640
+ environmentName: z4.string().min(1).optional(),
1641
+ name: z4.string().min(1),
1642
+ value: z4.string().min(1).max(32768),
1643
+ },
1644
+ },
1645
+ setVariableHandler,
1646
+ );
1647
+ };
1648
+ if (can("variable-sets:use")) {
1649
+ registerListTool(
1650
+ "variable_set_list",
1651
+ "List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
1652
+ );
1653
+ registerListTool(
1654
+ "environment_list",
1655
+ "(deprecated alias of variable_set_list) List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
1656
+ );
1657
+ }
1658
+
1659
+ if (can("variable-sets:manage")) {
1660
+ registerSetTool(
1661
+ "variable_set_set_variable",
1662
+ "Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
1663
+ );
1664
+ registerSetTool(
1665
+ "environment_set_variable",
1666
+ "(deprecated alias of variable_set_set_variable) Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
1667
+ );
869
1668
  }
870
1669
  }
871
1670
 
@@ -880,32 +1679,44 @@ function registerGitHubConnectTool(
880
1679
  options: McpServerOptions,
881
1680
  json: JsonResult,
882
1681
  ): void {
883
- server.registerTool("github_connect_link", {
884
- description: "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.",
885
- inputSchema: {},
886
- }, async () => {
887
- const { settings } = deps;
888
- const missing = githubAppMissingSettings(settings);
889
- const slug = settings.githubAppSlug?.trim() || null;
890
- if (missing.length > 0 || !slug) {
891
- return json({ configured: false, appSlug: slug, installUrl: null, missing });
892
- }
893
- const base = (settings.publicBaseUrl ?? settings.githubAppManifestBaseUrl ?? options.requestOrigin ?? "").replace(/\/+$/, "");
894
- if (!base) {
895
- throw new Error("github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment");
896
- }
897
- const state = createSignedState(deps.githubStateSecret, {
898
- accountId: grant.accountId,
899
- workspaceId: grant.workspaceId,
900
- });
901
- return json({
902
- configured: true,
903
- appSlug: slug,
904
- installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
905
- expiresInSeconds: stateMaxAgeSeconds,
906
- missing: [],
907
- });
908
- });
1682
+ server.registerTool(
1683
+ "github_connect_link",
1684
+ {
1685
+ description:
1686
+ "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.",
1687
+ inputSchema: {},
1688
+ },
1689
+ async () => {
1690
+ const { settings } = deps;
1691
+ const missing = githubAppMissingSettings(settings);
1692
+ const slug = settings.githubAppSlug?.trim() || null;
1693
+ if (missing.length > 0 || !slug) {
1694
+ return json({ configured: false, appSlug: slug, installUrl: null, missing });
1695
+ }
1696
+ const base = (
1697
+ settings.publicBaseUrl ??
1698
+ settings.githubAppManifestBaseUrl ??
1699
+ options.requestOrigin ??
1700
+ ""
1701
+ ).replace(/\/+$/, "");
1702
+ if (!base) {
1703
+ throw new Error(
1704
+ "github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment",
1705
+ );
1706
+ }
1707
+ const state = createSignedState(deps.githubStateSecret, {
1708
+ accountId: grant.accountId,
1709
+ workspaceId: grant.workspaceId,
1710
+ });
1711
+ return json({
1712
+ configured: true,
1713
+ appSlug: slug,
1714
+ installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
1715
+ expiresInSeconds: stateMaxAgeSeconds,
1716
+ missing: [],
1717
+ });
1718
+ },
1719
+ );
909
1720
  }
910
1721
 
911
1722
  // TOKEN-BROKER (B1): mint a FRESH short-lived GitHub App installation token for the
@@ -920,55 +1731,67 @@ function registerGitHubTokenTool(
920
1731
  sessionId: string,
921
1732
  json: JsonResult,
922
1733
  ): void {
923
- server.registerTool("github_token", {
924
- description: "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
925
- inputSchema: {},
926
- }, async () => {
927
- const session = await requireSession(deps.db, grant.workspaceId, sessionId);
928
- // Resolve the run-scoped installation + repository ids from THIS session's
929
- // repository resources (same shape sandboxEnvironmentForRun mints against). Only
930
- // private GitHub-App repos carry the installation/repository ids.
931
- const selected = (session.resources ?? []).flatMap((resource) => {
932
- if (resource.kind !== "repository") {
933
- return [];
1734
+ server.registerTool(
1735
+ "github_token",
1736
+ {
1737
+ description:
1738
+ "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
1739
+ inputSchema: {},
1740
+ },
1741
+ async () => {
1742
+ const session = await requireSession(deps.db, grant.workspaceId, sessionId);
1743
+ // Resolve the run-scoped installation + repository ids from THIS session's
1744
+ // repository resources (same shape sandboxEnvironmentForRun mints against). Only
1745
+ // private GitHub-App repos carry the installation/repository ids.
1746
+ const selected = (session.resources ?? []).flatMap((resource) => {
1747
+ if (resource.kind !== "repository") {
1748
+ return [];
1749
+ }
1750
+ const installationId = resource.githubInstallationId;
1751
+ const repositoryId = resource.githubRepositoryId;
1752
+ return typeof installationId === "number" &&
1753
+ installationId > 0 &&
1754
+ typeof repositoryId === "number" &&
1755
+ repositoryId > 0
1756
+ ? [{ installationId, repositoryId }]
1757
+ : [];
1758
+ });
1759
+ if (selected.length === 0) {
1760
+ throw new Error("this session has no GitHub App repository resources to mint a token for");
934
1761
  }
935
- const installationId = resource.githubInstallationId;
936
- const repositoryId = resource.githubRepositoryId;
937
- return typeof installationId === "number" && installationId > 0
938
- && typeof repositoryId === "number" && repositoryId > 0
939
- ? [{ installationId, repositoryId }]
940
- : [];
941
- });
942
- if (selected.length === 0) {
943
- throw new Error("this session has no GitHub App repository resources to mint a token for");
944
- }
945
- const installationId = selected[0]!.installationId;
946
- if (selected.some((item) => item.installationId !== installationId)) {
947
- throw new Error("GitHub App repository resources must belong to one installation");
948
- }
949
- const token = await createGitHubAppInstallationToken(deps.settings, {
950
- installationId,
951
- repositoryIds: selected.map((item) => item.repositoryId),
952
- });
953
- return json({
954
- token,
955
- tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
956
- });
957
- });
1762
+ const installationId = selected[0]!.installationId;
1763
+ if (selected.some((item) => item.installationId !== installationId)) {
1764
+ throw new Error("GitHub App repository resources must belong to one installation");
1765
+ }
1766
+ const token = await createGitHubAppInstallationToken(deps.settings, {
1767
+ installationId,
1768
+ repositoryIds: selected.map((item) => item.repositoryId),
1769
+ });
1770
+ return json({
1771
+ token,
1772
+ tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
1773
+ });
1774
+ },
1775
+ );
958
1776
  }
959
1777
 
960
1778
  // Defense-in-depth for invariant "agents cannot self-attach": the worker's
961
- // first-party delegated token never carries environments:use, so sandboxed
962
- // agents calling these MCP tools cannot attach a workspace environment.
963
- // Explicit detach (environmentId: null) is also an attachment change and is
1779
+ // first-party delegated token never carries variable-sets:use, so sandboxed
1780
+ // agents calling these MCP tools cannot attach a variable set.
1781
+ // Explicit detach (variableSetId: null) is also an attachment change and is
964
1782
  // blocked the same way.
965
- function requireEnvironmentsUseForMcpAttachment(grant: AccessGrant, environmentId: string | null | undefined): void {
966
- if (environmentId !== undefined && !hasPermission(grant.permissions, "environments:use")) {
967
- throw new Error("missing permission: environments:use");
1783
+ function requireVariableSetsUseForMcpAttachment(
1784
+ grant: AccessGrant,
1785
+ variableSetId: string | null | undefined,
1786
+ ): void {
1787
+ if (variableSetId !== undefined && !hasPermission(grant.permissions, "variable-sets:use")) {
1788
+ throw new Error("missing permission: variable-sets:use");
968
1789
  }
969
1790
  }
970
1791
 
971
- function repositoryWithScheduledTaskResource(repository: GitHubRepository): GitHubRepository & { resource: ResourceRef } {
1792
+ function repositoryWithScheduledTaskResource(
1793
+ repository: GitHubRepository,
1794
+ ): GitHubRepository & { resource: ResourceRef } {
972
1795
  const uri = normalizedRepositoryUri(repository.cloneUrl);
973
1796
  return {
974
1797
  ...repository,
@@ -977,7 +1800,9 @@ function repositoryWithScheduledTaskResource(repository: GitHubRepository): GitH
977
1800
  uri,
978
1801
  ref: repository.defaultBranch,
979
1802
  mountPath: repositoryMountPath(uri),
980
- ...(repository.private ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id } : {}),
1803
+ ...(repository.private
1804
+ ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id }
1805
+ : {}),
981
1806
  },
982
1807
  };
983
1808
  }