@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
- package/dist/chunk-DO2G3JSB.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +297 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -21
- package/src/app.ts +415 -147
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +27 -6
- package/src/index.ts +196 -74
- package/src/integrations/oauth-client.ts +592 -131
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1517 -692
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +72 -34
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +839 -329
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +159 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- 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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
12
|
+
addSessionSystemUpdate,
|
|
13
|
+
correctWorkspaceMemory,
|
|
14
|
+
countVariableSets,
|
|
15
|
+
beginRigChangeVerificationAttempt,
|
|
16
|
+
createVariableSet,
|
|
14
17
|
deleteScheduledTask,
|
|
15
|
-
|
|
18
|
+
encryptVariableSetValue,
|
|
16
19
|
getSession,
|
|
17
20
|
getSessionGoal,
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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 {
|
|
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(
|
|
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) => ({
|
|
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 =
|
|
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(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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:*,
|
|
134
|
-
// so agents can spawn/read sessions, manage
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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(
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
//
|
|
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/
|
|
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
|
|
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
|
-
|
|
804
|
-
server.registerTool(
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
1595
|
+
variableSet = await createVariableSet(deps.db, {
|
|
857
1596
|
accountId: grant.accountId,
|
|
858
1597
|
workspaceId: grant.workspaceId,
|
|
859
|
-
|
|
860
|
-
name: parsedName.data,
|
|
861
|
-
valueEncrypted: encryptEnvironmentValue(key, value),
|
|
1598
|
+
name: trimmedVariableSetName!,
|
|
862
1599
|
});
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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(
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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 =
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
962
|
-
// agents calling these MCP tools cannot attach a
|
|
963
|
-
// Explicit detach (
|
|
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
|
|
966
|
-
|
|
967
|
-
|
|
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(
|
|
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
|
|
1803
|
+
...(repository.private
|
|
1804
|
+
? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id }
|
|
1805
|
+
: {}),
|
|
981
1806
|
},
|
|
982
1807
|
};
|
|
983
1808
|
}
|