@opengeni/api-router 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateScheduledTaskRequest,
|
|
3
|
+
WorkspaceEnvironmentVariableName,
|
|
4
|
+
type AccessGrant,
|
|
5
|
+
type GitHubRepository,
|
|
6
|
+
type Permission,
|
|
7
|
+
type ResourceRef,
|
|
8
|
+
UpdateScheduledTaskRequest,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import {
|
|
11
|
+
countWorkspaceEnvironments,
|
|
12
|
+
createWorkspaceEnvironment,
|
|
13
|
+
deleteScheduledTask,
|
|
14
|
+
encryptEnvironmentValue,
|
|
15
|
+
getSession,
|
|
16
|
+
getSessionGoal,
|
|
17
|
+
getWorkspaceEnvironment,
|
|
18
|
+
getWorkspaceEnvironmentByName,
|
|
19
|
+
listGitHubInstallationIdsForWorkspace,
|
|
20
|
+
listScheduledTaskRuns,
|
|
21
|
+
listScheduledTasks,
|
|
22
|
+
listSessionEvents,
|
|
23
|
+
listSessions,
|
|
24
|
+
listSocialConnections,
|
|
25
|
+
listSocialPosts,
|
|
26
|
+
listWorkspaceEnvironments,
|
|
27
|
+
requireFile,
|
|
28
|
+
requireScheduledTask,
|
|
29
|
+
requireSession,
|
|
30
|
+
setSessionGoalStatus,
|
|
31
|
+
setWorkspaceEnvironmentVariable,
|
|
32
|
+
updateScheduledTask,
|
|
33
|
+
updateSessionGoal,
|
|
34
|
+
upsertSessionGoal,
|
|
35
|
+
} from "@opengeni/db";
|
|
36
|
+
import { appendAndPublishEvents } from "@opengeni/events";
|
|
37
|
+
import {
|
|
38
|
+
createGitHubAppInstallationToken,
|
|
39
|
+
createSignedState,
|
|
40
|
+
GitHubAppConfigurationError,
|
|
41
|
+
githubAppMissingSettings,
|
|
42
|
+
listGitHubAppRepositories,
|
|
43
|
+
stateMaxAgeSeconds,
|
|
44
|
+
} from "@opengeni/github";
|
|
45
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
46
|
+
import * as z4 from "zod/v4";
|
|
47
|
+
import { hasPermission } from "@opengeni/core";
|
|
48
|
+
import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
|
|
49
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
50
|
+
import {
|
|
51
|
+
assertAllowedEnvironmentVariableName,
|
|
52
|
+
MAX_ENVIRONMENTS_PER_WORKSPACE,
|
|
53
|
+
MAX_VARIABLES_PER_ENVIRONMENT,
|
|
54
|
+
recordEnvironmentAuditEvent,
|
|
55
|
+
requireEnvironmentEncryption,
|
|
56
|
+
} from "@opengeni/core";
|
|
57
|
+
import {
|
|
58
|
+
createValidatedScheduledTask,
|
|
59
|
+
manualScheduledTaskTriggerUsageKey,
|
|
60
|
+
manualScheduledTaskTriggerWorkflowId,
|
|
61
|
+
scheduledTaskToolsProvided,
|
|
62
|
+
scheduledTaskTriggerToken,
|
|
63
|
+
syncCreatedScheduledTask,
|
|
64
|
+
syncUpdatedScheduledTask,
|
|
65
|
+
validatedScheduledTaskUpdate,
|
|
66
|
+
} from "@opengeni/core";
|
|
67
|
+
import { acceptSessionUserMessage, createSessionForRequest, updateSessionTitle, workflowIdForSession } from "@opengeni/core";
|
|
68
|
+
import {
|
|
69
|
+
buildFleetContextForSession,
|
|
70
|
+
listFleet,
|
|
71
|
+
provisionSandbox,
|
|
72
|
+
runOnSandbox,
|
|
73
|
+
swapActiveSandbox,
|
|
74
|
+
type FleetContext,
|
|
75
|
+
type FleetServices,
|
|
76
|
+
type RunOnOp,
|
|
77
|
+
} from "@opengeni/core";
|
|
78
|
+
import { capEventPage, capSessionDetail } from "./session-view";
|
|
79
|
+
|
|
80
|
+
export type McpServerOptions = {
|
|
81
|
+
// Origin of the HTTP request that reached the MCP route; last-resort base
|
|
82
|
+
// for links the server mints (github_connect_link) when neither
|
|
83
|
+
// OPENGENI_PUBLIC_BASE_URL nor the manifest base URL is configured.
|
|
84
|
+
requestOrigin?: string | null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, options: McpServerOptions = {}): McpServer {
|
|
88
|
+
const server = new McpServer({
|
|
89
|
+
name: "opengeni",
|
|
90
|
+
version: "1.0.0",
|
|
91
|
+
});
|
|
92
|
+
const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] });
|
|
93
|
+
const can = (permission: Permission) => hasPermission(grant.permissions, permission);
|
|
94
|
+
|
|
95
|
+
// Session-scoped tools key off the worker-asserted sessionId claim (signed
|
|
96
|
+
// into the delegated token by the worker, never agent-controlled).
|
|
97
|
+
const sessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
|
|
98
|
+
// set_session_title names the agent's OWN session — pure session metadata,
|
|
99
|
+
// not a goal operation — so it is available on every session, gated only on
|
|
100
|
+
// the signed sessionId (NOT goals:manage, and NOT on a goal existing).
|
|
101
|
+
if (sessionId !== null) {
|
|
102
|
+
server.registerTool("set_session_title", {
|
|
103
|
+
description: "Set this session's display title to a concise 3-7 word summary. Call once early to name the session; calling again replaces it unless a human has manually set the title.",
|
|
104
|
+
inputSchema: { title: z4.string().min(1).max(200) },
|
|
105
|
+
}, async ({ title }) => {
|
|
106
|
+
const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
|
|
107
|
+
return json({ ok: true, updated: result.updated, title: result.title ?? title });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Goal tools require goals:manage (in the default first-party permission set).
|
|
111
|
+
if (sessionId !== null && can("goals:manage")) {
|
|
112
|
+
registerGoalTools(server, deps, grant, sessionId, json);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
|
|
116
|
+
// provision over the session's Modal box + the workspace's enrolled machines.
|
|
117
|
+
// Session-scoped like goals (they steer THIS session's active-sandbox pointer),
|
|
118
|
+
// so they register only when the grant carries the worker-signed sessionId claim
|
|
119
|
+
// (never agent-controlled). Gated on the selfhosted feature flag: the active
|
|
120
|
+
// pointer + swap are only meaningful when bring-your-own-compute is enabled.
|
|
121
|
+
if (sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
|
|
122
|
+
registerFleetTools(server, deps, grant, sessionId, json);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Orchestration, environment, and GitHub-connect tools are permission-gated
|
|
126
|
+
// at registration: a grant without the permission does not see the tool.
|
|
127
|
+
// Sandboxed workers reach this server with the first-party delegated
|
|
128
|
+
// permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
|
|
129
|
+
// POWERFUL BY DEFAULT — it carries sessions:*, environments:*, and github:use,
|
|
130
|
+
// so agents can spawn/read sessions, manage workspace environment variables,
|
|
131
|
+
// and mint GitHub install links out of the box. A user DEMOTES a specific
|
|
132
|
+
// session by setting a narrower session.firstPartyMcpPermissions (capped to
|
|
133
|
+
// the creator's own grant); operators still cap what any session can be given.
|
|
134
|
+
registerWorkspaceOrchestrationTools(server, deps, grant, can, json);
|
|
135
|
+
registerEnvironmentTools(server, deps, grant, can, json);
|
|
136
|
+
if (can("github:use")) {
|
|
137
|
+
registerGitHubConnectTool(server, deps, grant, options, json);
|
|
138
|
+
// TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
|
|
139
|
+
// worker-signed sessionId claim so it mints for THIS session's repos), gated on
|
|
140
|
+
// the same github:use capability as github_connect_link.
|
|
141
|
+
if (sessionId !== null) {
|
|
142
|
+
registerGitHubTokenTool(server, deps, grant, sessionId, json);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
server.registerTool("files_get_download_url", {
|
|
147
|
+
description: "Create a short-lived download URL for a ready file asset.",
|
|
148
|
+
inputSchema: { fileId: z4.string().uuid() },
|
|
149
|
+
}, async ({ fileId }) => {
|
|
150
|
+
if (!deps.objectStorage) {
|
|
151
|
+
throw new Error("object storage is not configured");
|
|
152
|
+
}
|
|
153
|
+
const file = await requireFile(deps.db, grant.workspaceId, fileId);
|
|
154
|
+
if (file.status !== "ready") {
|
|
155
|
+
throw new Error(`file is ${file.status}`);
|
|
156
|
+
}
|
|
157
|
+
const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
|
|
158
|
+
return json({
|
|
159
|
+
file: {
|
|
160
|
+
id: file.id,
|
|
161
|
+
filename: file.filename,
|
|
162
|
+
safeFilename: file.safeFilename,
|
|
163
|
+
contentType: file.contentType,
|
|
164
|
+
sizeBytes: file.sizeBytes,
|
|
165
|
+
sha256: file.sha256,
|
|
166
|
+
status: file.status,
|
|
167
|
+
createdAt: file.createdAt,
|
|
168
|
+
updatedAt: file.updatedAt,
|
|
169
|
+
},
|
|
170
|
+
downloadUrl: {
|
|
171
|
+
url: signed.url,
|
|
172
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.registerTool("github_repositories_list", {
|
|
178
|
+
description: "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
|
|
179
|
+
inputSchema: { limit: z4.number().int().positive().optional() },
|
|
180
|
+
}, async ({ limit }) => {
|
|
181
|
+
try {
|
|
182
|
+
const installationIds = await listGitHubInstallationIdsForWorkspace(deps.db, grant.workspaceId);
|
|
183
|
+
const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
|
|
184
|
+
const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
|
|
185
|
+
return json({ repositories: visible.map((repository) => repositoryWithScheduledTaskResource(repository)) });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
188
|
+
throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`);
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
server.registerTool("social_connections_list", {
|
|
195
|
+
description: "List connected social media accounts available to social media analysis packs.",
|
|
196
|
+
inputSchema: { limit: z4.number().int().positive().optional() },
|
|
197
|
+
}, async ({ limit }) => json({ connections: await listSocialConnections(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
|
|
198
|
+
|
|
199
|
+
server.registerTool("social_posts_recent", {
|
|
200
|
+
description: "List recent social media posts imported or synced into OpenGeni.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
connectionIds: z4.array(z4.string().uuid()).optional(),
|
|
203
|
+
since: z4.string().optional(),
|
|
204
|
+
windowHours: z4.number().int().positive().optional(),
|
|
205
|
+
limit: z4.number().int().positive().optional(),
|
|
206
|
+
},
|
|
207
|
+
}, async ({ connectionIds, since, windowHours, limit }) => {
|
|
208
|
+
const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
|
|
209
|
+
return json({
|
|
210
|
+
since: sinceDate.toISOString(),
|
|
211
|
+
posts: await listSocialPosts(deps.db, {
|
|
212
|
+
workspaceId: grant.workspaceId,
|
|
213
|
+
...(connectionIds?.length ? { connectionIds } : {}),
|
|
214
|
+
since: sinceDate,
|
|
215
|
+
limit: boundedMcpLimit(limit),
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
server.registerTool("social_daily_analysis_context", {
|
|
221
|
+
description: "Collect social account and recent post context for a daily marketing analysis run.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
connectionIds: z4.array(z4.string().uuid()).optional(),
|
|
224
|
+
documentBaseIds: z4.array(z4.string().uuid()).optional(),
|
|
225
|
+
since: z4.string().optional(),
|
|
226
|
+
windowHours: z4.number().int().positive().optional(),
|
|
227
|
+
limit: z4.number().int().positive().optional(),
|
|
228
|
+
},
|
|
229
|
+
}, async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
|
|
230
|
+
const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
|
|
231
|
+
const selectedIds = connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
|
|
232
|
+
const connections = selectedIds
|
|
233
|
+
? allConnections.filter((connection) => selectedIds.has(connection.id))
|
|
234
|
+
: allConnections.filter((connection) => connection.status === "connected");
|
|
235
|
+
if (selectedIds) {
|
|
236
|
+
const foundIds = new Set(connections.map((connection) => connection.id));
|
|
237
|
+
const missing = [...selectedIds].filter((id) => !foundIds.has(id));
|
|
238
|
+
if (missing.length > 0) {
|
|
239
|
+
throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
|
|
243
|
+
const posts = connections.length > 0
|
|
244
|
+
? await listSocialPosts(deps.db, {
|
|
245
|
+
workspaceId: grant.workspaceId,
|
|
246
|
+
connectionIds: connections.map((connection) => connection.id),
|
|
247
|
+
since: sinceDate,
|
|
248
|
+
limit: boundedMcpLimit(limit),
|
|
249
|
+
})
|
|
250
|
+
: [];
|
|
251
|
+
return json({
|
|
252
|
+
generatedAt: new Date().toISOString(),
|
|
253
|
+
window: {
|
|
254
|
+
since: sinceDate.toISOString(),
|
|
255
|
+
until: new Date().toISOString(),
|
|
256
|
+
},
|
|
257
|
+
documentBaseIds: documentBaseIds ?? [],
|
|
258
|
+
connections,
|
|
259
|
+
posts,
|
|
260
|
+
instructions: [
|
|
261
|
+
"Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
|
|
262
|
+
"Report data gaps explicitly when posts or metrics are missing.",
|
|
263
|
+
"Do not infer unpublished metrics or hidden platform data.",
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
server.registerTool("scheduled_tasks_list", {
|
|
269
|
+
description: "List scheduled tasks.",
|
|
270
|
+
inputSchema: { limit: z4.number().int().positive().optional() },
|
|
271
|
+
}, async ({ limit }) => json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }));
|
|
272
|
+
|
|
273
|
+
server.registerTool("scheduled_tasks_get", {
|
|
274
|
+
description: "Get one scheduled task.",
|
|
275
|
+
inputSchema: { id: z4.string().uuid() },
|
|
276
|
+
}, async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)));
|
|
277
|
+
|
|
278
|
+
server.registerTool("scheduled_tasks_create", {
|
|
279
|
+
description: "Create a scheduled task.",
|
|
280
|
+
inputSchema: {
|
|
281
|
+
name: z4.string(),
|
|
282
|
+
schedule: z4.unknown(),
|
|
283
|
+
runMode: z4.string().optional(),
|
|
284
|
+
overlapPolicy: z4.string().optional(),
|
|
285
|
+
agentConfig: z4.unknown(),
|
|
286
|
+
status: z4.string().optional(),
|
|
287
|
+
environmentId: z4.string().uuid().optional(),
|
|
288
|
+
metadata: z4.record(z4.string(), z4.unknown()).optional(),
|
|
289
|
+
},
|
|
290
|
+
}, async (args) => {
|
|
291
|
+
const payload = CreateScheduledTaskRequest.parse(args);
|
|
292
|
+
requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
|
|
293
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "schedule:create", quantity: 1 });
|
|
294
|
+
const task = await createValidatedScheduledTask({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, payload, toolsProvided: scheduledTaskToolsProvided(args) });
|
|
295
|
+
await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
|
|
296
|
+
return json(task);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
server.registerTool("scheduled_tasks_update", {
|
|
300
|
+
description: "Update a scheduled task.",
|
|
301
|
+
inputSchema: {
|
|
302
|
+
id: z4.string().uuid(),
|
|
303
|
+
name: z4.string().optional(),
|
|
304
|
+
schedule: z4.unknown().optional(),
|
|
305
|
+
runMode: z4.string().optional(),
|
|
306
|
+
overlapPolicy: z4.string().optional(),
|
|
307
|
+
agentConfig: z4.unknown().optional(),
|
|
308
|
+
status: z4.string().optional(),
|
|
309
|
+
environmentId: z4.string().uuid().nullable().optional(),
|
|
310
|
+
metadata: z4.record(z4.string(), z4.unknown()).optional(),
|
|
311
|
+
},
|
|
312
|
+
}, async ({ id, ...raw }) => {
|
|
313
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
314
|
+
const payload = UpdateScheduledTaskRequest.parse(raw);
|
|
315
|
+
requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
|
|
316
|
+
const update = await validatedScheduledTaskUpdate({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, existing, payload, toolsProvided: scheduledTaskToolsProvided(raw) });
|
|
317
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
|
|
318
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
319
|
+
return json(task);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
server.registerTool("scheduled_tasks_pause", {
|
|
323
|
+
description: "Pause a scheduled task.",
|
|
324
|
+
inputSchema: { id: z4.string().uuid() },
|
|
325
|
+
}, async ({ id }) => {
|
|
326
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
327
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "paused" });
|
|
328
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
329
|
+
return json(task);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
server.registerTool("scheduled_tasks_resume", {
|
|
333
|
+
description: "Resume a scheduled task.",
|
|
334
|
+
inputSchema: { id: z4.string().uuid() },
|
|
335
|
+
}, async ({ id }) => {
|
|
336
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
337
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "active" });
|
|
338
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
339
|
+
return json(task);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
server.registerTool("scheduled_tasks_trigger", {
|
|
343
|
+
description: "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
|
|
344
|
+
inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
|
|
345
|
+
}, async ({ id, triggerId }) => {
|
|
346
|
+
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
347
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
|
|
348
|
+
const triggerToken = scheduledTaskTriggerToken(triggerId);
|
|
349
|
+
const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken);
|
|
350
|
+
const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
|
|
351
|
+
await deps.workflowClient.triggerScheduledTask({ task, agentRunUsageIdempotencyKey, triggerWorkflowId });
|
|
352
|
+
await recordWorkspaceUsage(deps, {
|
|
353
|
+
accountId: grant.accountId,
|
|
354
|
+
workspaceId: grant.workspaceId,
|
|
355
|
+
subjectId: grant.subjectId,
|
|
356
|
+
eventType: "agent_run.created",
|
|
357
|
+
quantity: 1,
|
|
358
|
+
unit: "run",
|
|
359
|
+
sourceResourceType: "scheduled_task",
|
|
360
|
+
sourceResourceId: task.id,
|
|
361
|
+
idempotencyKey: agentRunUsageIdempotencyKey,
|
|
362
|
+
});
|
|
363
|
+
return json(task);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
server.registerTool("scheduled_tasks_delete", {
|
|
367
|
+
description: "Delete a scheduled task.",
|
|
368
|
+
inputSchema: { id: z4.string().uuid() },
|
|
369
|
+
}, async ({ id }) => {
|
|
370
|
+
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
371
|
+
await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
|
|
372
|
+
await deleteScheduledTask(deps.db, grant.workspaceId, id);
|
|
373
|
+
return json({ ok: true });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
server.registerTool("scheduled_task_runs_list", {
|
|
377
|
+
description: "List runs for a scheduled task.",
|
|
378
|
+
inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
|
|
379
|
+
}, async ({ taskId, limit }) => json({ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100) }));
|
|
380
|
+
|
|
381
|
+
return server;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function registerGoalTools(
|
|
385
|
+
server: McpServer,
|
|
386
|
+
deps: ApiRouteDeps,
|
|
387
|
+
grant: AccessGrant,
|
|
388
|
+
sessionId: string,
|
|
389
|
+
json: (value: unknown) => { content: Array<{ type: "text"; text: string }> },
|
|
390
|
+
): void {
|
|
391
|
+
server.registerTool("goal_set", {
|
|
392
|
+
description: "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
text: z4.string().min(1),
|
|
395
|
+
successCriteria: z4.string().min(1).optional(),
|
|
396
|
+
maxAutoContinuations: z4.number().int().positive().optional(),
|
|
397
|
+
},
|
|
398
|
+
}, async ({ text, successCriteria, maxAutoContinuations }) => {
|
|
399
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
400
|
+
const { goal, replaced } = await upsertSessionGoal(deps.db, {
|
|
401
|
+
accountId: grant.accountId,
|
|
402
|
+
workspaceId: grant.workspaceId,
|
|
403
|
+
sessionId,
|
|
404
|
+
text,
|
|
405
|
+
successCriteria: successCriteria ?? null,
|
|
406
|
+
maxAutoContinuations: maxAutoContinuations ?? null,
|
|
407
|
+
createdBy: "agent",
|
|
408
|
+
});
|
|
409
|
+
await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
410
|
+
type: "goal.set",
|
|
411
|
+
payload: {
|
|
412
|
+
goalId: goal.id,
|
|
413
|
+
text: goal.text,
|
|
414
|
+
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
415
|
+
version: goal.version,
|
|
416
|
+
actor: "agent",
|
|
417
|
+
replaced,
|
|
418
|
+
},
|
|
419
|
+
}]);
|
|
420
|
+
return json(goal);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
server.registerTool("goal_update", {
|
|
424
|
+
description: "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
|
|
425
|
+
inputSchema: {
|
|
426
|
+
text: z4.string().min(1).optional(),
|
|
427
|
+
successCriteria: z4.string().min(1).optional(),
|
|
428
|
+
progressNote: z4.string().min(1).optional(),
|
|
429
|
+
},
|
|
430
|
+
}, async ({ text, successCriteria, progressNote }) => {
|
|
431
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
432
|
+
const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
|
|
433
|
+
if (!existing) {
|
|
434
|
+
throw new Error("this session has no goal; use goal_set first");
|
|
435
|
+
}
|
|
436
|
+
if (existing.status === "completed") {
|
|
437
|
+
throw new Error("session goal is completed; use goal_set to start a new goal");
|
|
438
|
+
}
|
|
439
|
+
const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
|
|
440
|
+
...(text !== undefined ? { text } : {}),
|
|
441
|
+
...(successCriteria !== undefined ? { successCriteria } : {}),
|
|
442
|
+
});
|
|
443
|
+
await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
444
|
+
type: "goal.updated",
|
|
445
|
+
payload: {
|
|
446
|
+
goalId: goal.id,
|
|
447
|
+
text: goal.text,
|
|
448
|
+
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
449
|
+
...(progressNote ? { progressNote } : {}),
|
|
450
|
+
version: goal.version,
|
|
451
|
+
actor: "agent",
|
|
452
|
+
},
|
|
453
|
+
}]);
|
|
454
|
+
return json(goal);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
server.registerTool("goal_complete", {
|
|
458
|
+
description: "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). This is the explicit stop signal: no further continuation turns are synthesized.",
|
|
459
|
+
inputSchema: { evidence: z4.string().min(1) },
|
|
460
|
+
}, async ({ evidence }) => {
|
|
461
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
462
|
+
const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
|
|
463
|
+
if (!existing) {
|
|
464
|
+
throw new Error("this session has no goal; use goal_set first");
|
|
465
|
+
}
|
|
466
|
+
const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
|
|
467
|
+
status: "completed",
|
|
468
|
+
evidence,
|
|
469
|
+
});
|
|
470
|
+
if (changed) {
|
|
471
|
+
await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
472
|
+
type: "goal.completed",
|
|
473
|
+
payload: { goalId: goal.id, evidence, version: goal.version },
|
|
474
|
+
}]);
|
|
475
|
+
}
|
|
476
|
+
return json(goal);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
server.registerTool("goal_pause", {
|
|
480
|
+
description: "Pause the session goal with a rationale (blocked, not productive, needs human input). This is the explicit stop signal: no further continuation turns are synthesized until the goal is resumed or replaced.",
|
|
481
|
+
inputSchema: { rationale: z4.string().min(1) },
|
|
482
|
+
}, async ({ rationale }) => {
|
|
483
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
484
|
+
const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
|
|
485
|
+
if (!existing) {
|
|
486
|
+
throw new Error("this session has no goal; use goal_set first");
|
|
487
|
+
}
|
|
488
|
+
const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
|
|
489
|
+
status: "paused",
|
|
490
|
+
rationale,
|
|
491
|
+
pausedReason: "agent",
|
|
492
|
+
});
|
|
493
|
+
if (changed) {
|
|
494
|
+
await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
495
|
+
type: "goal.paused",
|
|
496
|
+
payload: {
|
|
497
|
+
goalId: goal.id,
|
|
498
|
+
actor: "agent",
|
|
499
|
+
reason: "agent",
|
|
500
|
+
rationale,
|
|
501
|
+
autoContinuations: goal.autoContinuations,
|
|
502
|
+
noProgressStreak: goal.noProgressStreak,
|
|
503
|
+
},
|
|
504
|
+
}]);
|
|
505
|
+
}
|
|
506
|
+
return json(goal);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
type JsonResult = (value: unknown) => { content: Array<{ type: "text"; text: string }> };
|
|
511
|
+
|
|
512
|
+
// Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
|
|
513
|
+
// session's active-sandbox pointer + reach the workspace's enrolled machines),
|
|
514
|
+
// registered only with the worker-signed sessionId claim + the selfhosted flag.
|
|
515
|
+
// The agent uses these to list the fleet (its Modal box + enrolled machines),
|
|
516
|
+
// attach/swap the active sandbox mid-conversation (heterogeneous, single-active,
|
|
517
|
+
// epoch-fenced), run a one-off op on a specific machine without swapping, and
|
|
518
|
+
// surface provisioning (enroll-a-machine) instructions to a human.
|
|
519
|
+
function registerFleetTools(
|
|
520
|
+
server: McpServer,
|
|
521
|
+
deps: ApiRouteDeps,
|
|
522
|
+
grant: AccessGrant,
|
|
523
|
+
sessionId: string,
|
|
524
|
+
json: JsonResult,
|
|
525
|
+
): void {
|
|
526
|
+
const services: FleetServices = { db: deps.db, settings: deps.settings, bus: deps.bus };
|
|
527
|
+
|
|
528
|
+
// Resolve the session's group sandbox (the default/home fleet member) at
|
|
529
|
+
// call-time via the shared helper (same context the user-authenticated swap
|
|
530
|
+
// REST route builds). Throws when the session has no box (backend:none) — the
|
|
531
|
+
// fleet is only meaningful for a session that runs in a sandbox.
|
|
532
|
+
const fleetContext = async (): Promise<FleetContext> =>
|
|
533
|
+
await buildFleetContextForSession(deps, {
|
|
534
|
+
accountId: grant.accountId,
|
|
535
|
+
workspaceId: grant.workspaceId,
|
|
536
|
+
sessionId,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
server.registerTool("sandboxes_list", {
|
|
540
|
+
description:
|
|
541
|
+
"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.",
|
|
542
|
+
inputSchema: {},
|
|
543
|
+
}, async () => json(await listFleet(services, await fleetContext())));
|
|
544
|
+
|
|
545
|
+
server.registerTool("sandbox_attach", {
|
|
546
|
+
description:
|
|
547
|
+
"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.",
|
|
548
|
+
inputSchema: { target: z4.string().min(1) },
|
|
549
|
+
}, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
|
|
550
|
+
|
|
551
|
+
server.registerTool("sandbox_swap", {
|
|
552
|
+
description:
|
|
553
|
+
"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.",
|
|
554
|
+
inputSchema: { target: z4.string().min(1) },
|
|
555
|
+
}, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
|
|
556
|
+
|
|
557
|
+
server.registerTool("run_on", {
|
|
558
|
+
description:
|
|
559
|
+
"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.",
|
|
560
|
+
inputSchema: {
|
|
561
|
+
target: z4.string().min(1),
|
|
562
|
+
op: z4.discriminatedUnion("kind", [
|
|
563
|
+
z4.object({ kind: z4.literal("exec"), cmd: z4.string().min(1), workdir: z4.string().optional() }),
|
|
564
|
+
z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
|
|
565
|
+
z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
|
|
566
|
+
]),
|
|
567
|
+
},
|
|
568
|
+
}, async ({ target, op }) => json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)));
|
|
569
|
+
|
|
570
|
+
server.registerTool("sandbox_provision", {
|
|
571
|
+
description:
|
|
572
|
+
"Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record (its box materializes on first swap).",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
kind: z4.enum(["selfhosted", "modal"]),
|
|
575
|
+
name: z4.string().min(1).max(120).optional(),
|
|
576
|
+
},
|
|
577
|
+
}, async ({ kind, name }) => json(await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) })));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Workspace orchestration for manager-style agents: sessions are listed,
|
|
581
|
+
// inspected, spawned, and steered with the same domain functions the REST
|
|
582
|
+
// routes use, so limits, validation, and usage metering cannot drift.
|
|
583
|
+
function registerWorkspaceOrchestrationTools(
|
|
584
|
+
server: McpServer,
|
|
585
|
+
deps: ApiRouteDeps,
|
|
586
|
+
grant: AccessGrant,
|
|
587
|
+
can: (permission: Permission) => boolean,
|
|
588
|
+
json: JsonResult,
|
|
589
|
+
): void {
|
|
590
|
+
if (can("sessions:read")) {
|
|
591
|
+
server.registerTool("sessions_list", {
|
|
592
|
+
description: "List sessions in this workspace, newest first.",
|
|
593
|
+
inputSchema: { limit: z4.number().int().positive().optional() },
|
|
594
|
+
}, async ({ limit }) => json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
|
|
595
|
+
|
|
596
|
+
server.registerTool("session_get", {
|
|
597
|
+
description: "Get one session: status, goal-bearing metadata, resources, tools, and environment attachment (names/ids only, never variable values). Unbounded agent-set fields (metadata, initial message) are clamped so monitoring another session cannot flood this context.",
|
|
598
|
+
inputSchema: { sessionId: z4.string().uuid() },
|
|
599
|
+
}, async ({ sessionId }) => {
|
|
600
|
+
const session = await getSession(deps.db, grant.workspaceId, sessionId);
|
|
601
|
+
if (!session) {
|
|
602
|
+
throw new Error("session not found");
|
|
603
|
+
}
|
|
604
|
+
return json(capSessionDetail(session));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
server.registerTool("session_events", {
|
|
608
|
+
description: "Read a session's event timeline (oldest first), to monitor another session's progress. Pass `after` = the highest event `sequence` already seen to page forward; the response's `nextAfter` is that cursor. The response is BYTE-CAPPED for a monitoring glance: fat per-event payloads (a worker's verbatim tool outputs, message/reasoning bodies) are clamped, and an over-budget page is reduced to its head + tail with a marker — page the gap with `after`/`limit`, or read the worker's session notebook, if you need omitted content verbatim. `nextAfter` always advances past every event the page covered, so paging never skips real events.",
|
|
609
|
+
inputSchema: {
|
|
610
|
+
sessionId: z4.string().uuid(),
|
|
611
|
+
after: z4.number().int().nonnegative().optional(),
|
|
612
|
+
limit: z4.number().int().positive().optional(),
|
|
613
|
+
},
|
|
614
|
+
}, async ({ sessionId, after, limit }) => {
|
|
615
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
616
|
+
const events = await listSessionEvents(deps.db, grant.workspaceId, sessionId, after ?? 0, boundedMcpLimit(limit));
|
|
617
|
+
const capped = capEventPage(events);
|
|
618
|
+
return json({
|
|
619
|
+
events: capped.events,
|
|
620
|
+
nextAfter: capped.nextAfter ?? after ?? 0,
|
|
621
|
+
...(capped.truncated ? { truncated: true } : {}),
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (can("sessions:create")) {
|
|
627
|
+
server.registerTool("session_create", {
|
|
628
|
+
description: "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and workspace environment attachment. Environment attachment happens at creation only — it cannot be added to a running session — and requires the environments:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
|
|
629
|
+
inputSchema: {
|
|
630
|
+
initialMessage: z4.string().min(1),
|
|
631
|
+
goal: z4.unknown().optional(),
|
|
632
|
+
resources: z4.array(z4.unknown()).optional(),
|
|
633
|
+
tools: z4.array(z4.unknown()).optional(),
|
|
634
|
+
environmentId: z4.string().uuid().optional(),
|
|
635
|
+
model: z4.string().min(1).optional(),
|
|
636
|
+
reasoningEffort: z4.string().optional(),
|
|
637
|
+
sandboxBackend: z4.string().optional(),
|
|
638
|
+
// Create-time machine targeting: an enrolled sandbox id (from
|
|
639
|
+
// sandboxes_list) to run the spawned session on. Seeds the active-sandbox
|
|
640
|
+
// pointer at creation so the FIRST turn lands on the chosen machine
|
|
641
|
+
// (race-free). Ownership + liveness are validated in the domain via the
|
|
642
|
+
// same path as sandbox_swap; an unowned/offline/unknown target 422s.
|
|
643
|
+
targetSandboxId: z4.string().uuid().optional(),
|
|
644
|
+
// The working directory (cwd) for a machine target: the path/cwd base the
|
|
645
|
+
// spawned session's agent exec, terminal, and file dock run under. A
|
|
646
|
+
// workspace_root-relative subdir or an absolute machine path. Only valid
|
|
647
|
+
// WITH targetSandboxId (workingDir alone 422s); omitted ⇒ workspace_root.
|
|
648
|
+
workingDir: z4.string().optional(),
|
|
649
|
+
metadata: z4.record(z4.string(), z4.unknown()).optional(),
|
|
650
|
+
// Workspace-scoped CREATE idempotency key: a retried session_create with
|
|
651
|
+
// the same key returns the already-spawned worker instead of a duplicate.
|
|
652
|
+
idempotencyKey: z4.string().min(1).max(200).optional(),
|
|
653
|
+
// First-party MCP token permissions for the spawned session; every
|
|
654
|
+
// permission must be held by this grant (validated in the domain).
|
|
655
|
+
firstPartyMcpPermissions: z4.array(z4.string()).optional(),
|
|
656
|
+
// Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
|
|
657
|
+
// creator's box — one filesystem/repo/desktop, N independent conversations;
|
|
658
|
+
// this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
|
|
659
|
+
// repo set or a genuinely separate filesystem), or {groupId} (a sibling
|
|
660
|
+
// session's `sandboxGroupId` from a prior session_create response) to join
|
|
661
|
+
// that specific sibling's box.
|
|
662
|
+
// Shared state must be compatible: a shared box requires the SAME image
|
|
663
|
+
// (rejected at the lease layer, B3) and — because the box's environment is
|
|
664
|
+
// fixed at creation under the current mechanics — the SAME workspace
|
|
665
|
+
// Environment. The domain layer is env-aware: an inherited default with a
|
|
666
|
+
// different environmentId silently gets its OWN box (the spawn still works),
|
|
667
|
+
// while an explicit shared/{groupId} with a mismatched environment 422s at
|
|
668
|
+
// create. When the Environment is eventually evicted from the box manifest
|
|
669
|
+
// (per-exec, like the git token), the env check dissolves on its own.
|
|
670
|
+
// The description below is what the AGENT sees (this comment is invisible to
|
|
671
|
+
// it); keep the two in sync.
|
|
672
|
+
sandbox: z4.union([
|
|
673
|
+
z4.literal("shared"),
|
|
674
|
+
z4.literal("new"),
|
|
675
|
+
z4.object({ groupId: z4.string().uuid() }),
|
|
676
|
+
]).describe(
|
|
677
|
+
"Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT environment than the creator's box, the platform automatically gives it its own box (the box environment is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same environment (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
|
|
678
|
+
).optional(),
|
|
679
|
+
// The parent (manager) session is auto-inferred from the caller's
|
|
680
|
+
// worker-signed sessionId claim, so a spawned worker's completion wakes
|
|
681
|
+
// its manager automatically. There is deliberately no caller-supplied
|
|
682
|
+
// parent parameter: it would let a sessions:create grant target an
|
|
683
|
+
// arbitrary session's wake channel without sessions:control on it.
|
|
684
|
+
},
|
|
685
|
+
}, async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (can("sessions:control")) {
|
|
689
|
+
server.registerTool("session_send_message", {
|
|
690
|
+
description: "Post a user message into an existing session; the session queues a turn and resumes if idle.",
|
|
691
|
+
inputSchema: {
|
|
692
|
+
sessionId: z4.string().uuid(),
|
|
693
|
+
text: z4.string().min(1),
|
|
694
|
+
},
|
|
695
|
+
}, async ({ sessionId, text }) => {
|
|
696
|
+
const { accepted, turn } = await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
|
|
697
|
+
text,
|
|
698
|
+
toolsProvided: false,
|
|
699
|
+
});
|
|
700
|
+
return json({ event: accepted, turnId: turn.id });
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
server.registerTool("session_interrupt", {
|
|
704
|
+
description:
|
|
705
|
+
"Interrupt a session in this workspace. mode='stop' (default) cancels the current turn AND pauses the session's active goal so it halts. mode='steer' cancels the current turn WITHOUT pausing the goal, so the session picks up its next queued turn (or, if nothing is queued, continues toward its active goal) — pair it with a preceding session_send_message to redirect a running session. Works whether the target is mid-turn or idle.",
|
|
706
|
+
inputSchema: {
|
|
707
|
+
sessionId: z4.string().uuid(),
|
|
708
|
+
mode: z4.enum(["stop", "steer"]).optional(),
|
|
709
|
+
},
|
|
710
|
+
}, async ({ sessionId, mode }) => {
|
|
711
|
+
await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
712
|
+
const appended = await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
713
|
+
type: "user.interrupt",
|
|
714
|
+
payload: mode === "steer" ? { reason: "steer" } : {},
|
|
715
|
+
}]);
|
|
716
|
+
const accepted = appended[0];
|
|
717
|
+
if (!accepted) {
|
|
718
|
+
throw new Error("failed to append interrupt event");
|
|
719
|
+
}
|
|
720
|
+
await deps.workflowClient.signalInterrupt({
|
|
721
|
+
accountId: grant.accountId,
|
|
722
|
+
workspaceId: grant.workspaceId,
|
|
723
|
+
sessionId,
|
|
724
|
+
eventId: accepted.id,
|
|
725
|
+
workflowId: workflowIdForSession(sessionId),
|
|
726
|
+
});
|
|
727
|
+
return json({ event: accepted });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
server.registerTool("set_other_session_title", {
|
|
731
|
+
description: "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
|
|
732
|
+
inputSchema: {
|
|
733
|
+
session_id: z4.string().uuid(),
|
|
734
|
+
title: z4.string().min(1).max(200),
|
|
735
|
+
},
|
|
736
|
+
}, async ({ session_id, title }) => {
|
|
737
|
+
await requireSession(deps.db, grant.workspaceId, session_id);
|
|
738
|
+
const result = await updateSessionTitle(deps, grant.workspaceId, session_id, title, "agent");
|
|
739
|
+
return json({ ok: true, updated: result.updated, title: result.title ?? title });
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Environment management for manager-style agents. v1 deliberately accepts
|
|
745
|
+
// variable VALUES in plain tool arguments: the calling model is trusted with
|
|
746
|
+
// the secrets it is persisting (see docs/environments.md). Reads stay
|
|
747
|
+
// write-only — responses carry names and metadata, never values.
|
|
748
|
+
function registerEnvironmentTools(
|
|
749
|
+
server: McpServer,
|
|
750
|
+
deps: ApiRouteDeps,
|
|
751
|
+
grant: AccessGrant,
|
|
752
|
+
can: (permission: Permission) => boolean,
|
|
753
|
+
json: JsonResult,
|
|
754
|
+
): void {
|
|
755
|
+
if (can("environments:use")) {
|
|
756
|
+
server.registerTool("environment_list", {
|
|
757
|
+
description: "List workspace environments with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
|
|
758
|
+
inputSchema: {},
|
|
759
|
+
}, async () => json({ environments: await listWorkspaceEnvironments(deps.db, grant.workspaceId) }));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (can("environments:manage")) {
|
|
763
|
+
server.registerTool("environment_set_variable", {
|
|
764
|
+
description: "Set or rotate one variable in a workspace environment. Target by environmentId, or by environmentName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the environment is attached to; it is never readable back through any API.",
|
|
765
|
+
inputSchema: {
|
|
766
|
+
environmentId: z4.string().uuid().optional(),
|
|
767
|
+
environmentName: z4.string().min(1).optional(),
|
|
768
|
+
name: z4.string().min(1),
|
|
769
|
+
value: z4.string().min(1).max(32768),
|
|
770
|
+
},
|
|
771
|
+
}, async ({ environmentId, environmentName, name, value }) => {
|
|
772
|
+
const key = requireEnvironmentEncryption(deps.settings);
|
|
773
|
+
const parsedName = WorkspaceEnvironmentVariableName.safeParse(name);
|
|
774
|
+
if (!parsedName.success) {
|
|
775
|
+
throw new Error("environment variable names must match ^[A-Z][A-Z0-9_]*$");
|
|
776
|
+
}
|
|
777
|
+
assertAllowedEnvironmentVariableName(parsedName.data);
|
|
778
|
+
if ((environmentId === undefined) === (environmentName === undefined)) {
|
|
779
|
+
throw new Error("provide exactly one of environmentId or environmentName");
|
|
780
|
+
}
|
|
781
|
+
const trimmedEnvironmentName = environmentName?.trim();
|
|
782
|
+
if (environmentName !== undefined && !trimmedEnvironmentName) {
|
|
783
|
+
throw new Error("environment name is required");
|
|
784
|
+
}
|
|
785
|
+
let created = false;
|
|
786
|
+
let environment = environmentId !== undefined
|
|
787
|
+
? await getWorkspaceEnvironment(deps.db, grant.workspaceId, environmentId)
|
|
788
|
+
: await getWorkspaceEnvironmentByName(deps.db, grant.workspaceId, trimmedEnvironmentName!);
|
|
789
|
+
if (!environment && environmentId !== undefined) {
|
|
790
|
+
throw new Error("environment not found");
|
|
791
|
+
}
|
|
792
|
+
if (!environment) {
|
|
793
|
+
if (await countWorkspaceEnvironments(deps.db, grant.workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
|
|
794
|
+
throw new Error(`a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} environments`);
|
|
795
|
+
}
|
|
796
|
+
environment = await createWorkspaceEnvironment(deps.db, {
|
|
797
|
+
accountId: grant.accountId,
|
|
798
|
+
workspaceId: grant.workspaceId,
|
|
799
|
+
name: trimmedEnvironmentName!,
|
|
800
|
+
});
|
|
801
|
+
created = true;
|
|
802
|
+
await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.created", environmentId: environment.id });
|
|
803
|
+
}
|
|
804
|
+
const exists = environment.variables.some((variable) => variable.name === parsedName.data);
|
|
805
|
+
if (!exists && environment.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
|
|
806
|
+
throw new Error(`an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
|
|
807
|
+
}
|
|
808
|
+
const metadata = await setWorkspaceEnvironmentVariable(deps.db, {
|
|
809
|
+
accountId: grant.accountId,
|
|
810
|
+
workspaceId: grant.workspaceId,
|
|
811
|
+
environmentId: environment.id,
|
|
812
|
+
name: parsedName.data,
|
|
813
|
+
valueEncrypted: encryptEnvironmentValue(key, value),
|
|
814
|
+
});
|
|
815
|
+
await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: parsedName.data });
|
|
816
|
+
return json({
|
|
817
|
+
environment: { id: environment.id, name: environment.name, created },
|
|
818
|
+
variable: metadata,
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// GitHub connect link for manager-style agents: mirrors GET .../github/app but
|
|
825
|
+
// returns a browser entry URL (.../github/connect) that plants the CSRF state
|
|
826
|
+
// cookie before forwarding to GitHub, because an MCP-issued link is opened in
|
|
827
|
+
// a browser that never called the API directly.
|
|
828
|
+
function registerGitHubConnectTool(
|
|
829
|
+
server: McpServer,
|
|
830
|
+
deps: ApiRouteDeps,
|
|
831
|
+
grant: AccessGrant,
|
|
832
|
+
options: McpServerOptions,
|
|
833
|
+
json: JsonResult,
|
|
834
|
+
): void {
|
|
835
|
+
server.registerTool("github_connect_link", {
|
|
836
|
+
description: "Create a workspace-bound GitHub App install link to share with a human. Opening it redirects to GitHub to install the app and select repositories for this workspace; completing the connection requires the person to be signed in to this OpenGeni deployment with github:manage. The link expires.",
|
|
837
|
+
inputSchema: {},
|
|
838
|
+
}, async () => {
|
|
839
|
+
const { settings } = deps;
|
|
840
|
+
const missing = githubAppMissingSettings(settings);
|
|
841
|
+
const slug = settings.githubAppSlug?.trim() || null;
|
|
842
|
+
if (missing.length > 0 || !slug) {
|
|
843
|
+
return json({ configured: false, appSlug: slug, installUrl: null, missing });
|
|
844
|
+
}
|
|
845
|
+
const base = (settings.publicBaseUrl ?? settings.githubAppManifestBaseUrl ?? options.requestOrigin ?? "").replace(/\/+$/, "");
|
|
846
|
+
if (!base) {
|
|
847
|
+
throw new Error("github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment");
|
|
848
|
+
}
|
|
849
|
+
const state = createSignedState(deps.githubStateSecret, {
|
|
850
|
+
accountId: grant.accountId,
|
|
851
|
+
workspaceId: grant.workspaceId,
|
|
852
|
+
});
|
|
853
|
+
return json({
|
|
854
|
+
configured: true,
|
|
855
|
+
appSlug: slug,
|
|
856
|
+
installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
|
|
857
|
+
expiresInSeconds: stateMaxAgeSeconds,
|
|
858
|
+
missing: [],
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// TOKEN-BROKER (B1): mint a FRESH short-lived GitHub App installation token for the
|
|
864
|
+
// session's repository resources. The agent calls this to refresh git auth before
|
|
865
|
+
// the current token expires. The MCP server CANNOT write the box, so the tool RETURNS
|
|
866
|
+
// the token as JSON; the agent writes it to the token file (via exec) to refresh
|
|
867
|
+
// GIT_ASKPASS. Same github:use capability gate as github_connect_link.
|
|
868
|
+
function registerGitHubTokenTool(
|
|
869
|
+
server: McpServer,
|
|
870
|
+
deps: ApiRouteDeps,
|
|
871
|
+
grant: AccessGrant,
|
|
872
|
+
sessionId: string,
|
|
873
|
+
json: JsonResult,
|
|
874
|
+
): void {
|
|
875
|
+
server.registerTool("github_token", {
|
|
876
|
+
description: "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
|
|
877
|
+
inputSchema: {},
|
|
878
|
+
}, async () => {
|
|
879
|
+
const session = await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
880
|
+
// Resolve the run-scoped installation + repository ids from THIS session's
|
|
881
|
+
// repository resources (same shape sandboxEnvironmentForRun mints against). Only
|
|
882
|
+
// private GitHub-App repos carry the installation/repository ids.
|
|
883
|
+
const selected = (session.resources ?? []).flatMap((resource) => {
|
|
884
|
+
if (resource.kind !== "repository") {
|
|
885
|
+
return [];
|
|
886
|
+
}
|
|
887
|
+
const installationId = resource.githubInstallationId;
|
|
888
|
+
const repositoryId = resource.githubRepositoryId;
|
|
889
|
+
return typeof installationId === "number" && installationId > 0
|
|
890
|
+
&& typeof repositoryId === "number" && repositoryId > 0
|
|
891
|
+
? [{ installationId, repositoryId }]
|
|
892
|
+
: [];
|
|
893
|
+
});
|
|
894
|
+
if (selected.length === 0) {
|
|
895
|
+
throw new Error("this session has no GitHub App repository resources to mint a token for");
|
|
896
|
+
}
|
|
897
|
+
const installationId = selected[0]!.installationId;
|
|
898
|
+
if (selected.some((item) => item.installationId !== installationId)) {
|
|
899
|
+
throw new Error("GitHub App repository resources must belong to one installation");
|
|
900
|
+
}
|
|
901
|
+
const token = await createGitHubAppInstallationToken(deps.settings, {
|
|
902
|
+
installationId,
|
|
903
|
+
repositoryIds: selected.map((item) => item.repositoryId),
|
|
904
|
+
});
|
|
905
|
+
return json({
|
|
906
|
+
token,
|
|
907
|
+
tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Defense-in-depth for invariant "agents cannot self-attach": the worker's
|
|
913
|
+
// first-party delegated token never carries environments:use, so sandboxed
|
|
914
|
+
// agents calling these MCP tools cannot attach a workspace environment.
|
|
915
|
+
// Explicit detach (environmentId: null) is also an attachment change and is
|
|
916
|
+
// blocked the same way.
|
|
917
|
+
function requireEnvironmentsUseForMcpAttachment(grant: AccessGrant, environmentId: string | null | undefined): void {
|
|
918
|
+
if (environmentId !== undefined && !hasPermission(grant.permissions, "environments:use")) {
|
|
919
|
+
throw new Error("missing permission: environments:use");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function repositoryWithScheduledTaskResource(repository: GitHubRepository): GitHubRepository & { resource: ResourceRef } {
|
|
924
|
+
const uri = normalizedRepositoryUri(repository.cloneUrl);
|
|
925
|
+
return {
|
|
926
|
+
...repository,
|
|
927
|
+
resource: {
|
|
928
|
+
kind: "repository",
|
|
929
|
+
uri,
|
|
930
|
+
ref: repository.defaultBranch,
|
|
931
|
+
mountPath: repositoryMountPath(uri),
|
|
932
|
+
...(repository.private ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id } : {}),
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function normalizedRepositoryUri(value: string): string {
|
|
938
|
+
const url = new URL(value);
|
|
939
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
940
|
+
return `https://${url.hostname.toLowerCase()}/${path}.git`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function repositoryMountPath(uri: string): string {
|
|
944
|
+
const url = new URL(uri);
|
|
945
|
+
return `repos/${url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function boundedMcpLimit(limit: number | undefined): number {
|
|
949
|
+
if (!limit || !Number.isFinite(limit)) {
|
|
950
|
+
return 100;
|
|
951
|
+
}
|
|
952
|
+
return Math.min(500, Math.max(1, Math.floor(limit)));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function parseMcpDate(raw: string, label: string): Date {
|
|
956
|
+
const date = new Date(raw);
|
|
957
|
+
if (Number.isNaN(date.getTime())) {
|
|
958
|
+
throw new Error(`${label} must be an ISO date-time`);
|
|
959
|
+
}
|
|
960
|
+
return date;
|
|
961
|
+
}
|