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