@opengeni/core 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/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2627 @@
|
|
|
1
|
+
// src/sandbox/fleet.ts
|
|
2
|
+
import {
|
|
3
|
+
getEnrollment,
|
|
4
|
+
getSandbox as getSandbox2,
|
|
5
|
+
listSandboxes,
|
|
6
|
+
readActiveSandbox as readActiveSandbox2,
|
|
7
|
+
requireSession,
|
|
8
|
+
setActiveSandbox
|
|
9
|
+
} from "@opengeni/db";
|
|
10
|
+
import {
|
|
11
|
+
NatsControlRpc as NatsControlRpc2,
|
|
12
|
+
selfhostedLiveness,
|
|
13
|
+
SelfhostedSession
|
|
14
|
+
} from "@opengeni/runtime/sandbox";
|
|
15
|
+
import { HTTPException } from "hono/http-exception";
|
|
16
|
+
|
|
17
|
+
// src/sandbox/routing.ts
|
|
18
|
+
import { getSandbox, readActiveSandbox } from "@opengeni/db";
|
|
19
|
+
import {
|
|
20
|
+
makeActiveBackendResolver,
|
|
21
|
+
NatsControlRpc,
|
|
22
|
+
RoutingSandboxSession
|
|
23
|
+
} from "@opengeni/runtime/sandbox";
|
|
24
|
+
function relayConfigFromSettings(settings) {
|
|
25
|
+
const raw = settings.selfhostedRelayUrl?.trim();
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return { host: "relay.opengeni.local", port: 443, tls: true, path: "/stream" };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(raw.includes("://") ? raw : `wss://${raw}`);
|
|
31
|
+
const tls = url.protocol === "wss:" || url.protocol === "https:";
|
|
32
|
+
const port = url.port ? Number(url.port) : tls ? 443 : 80;
|
|
33
|
+
const path = url.pathname && url.pathname !== "/" ? url.pathname : "/stream";
|
|
34
|
+
return { host: url.hostname, port, tls, path };
|
|
35
|
+
} catch {
|
|
36
|
+
return { host: raw, port: 443, tls: true, path: "/stream" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function relayDialBaseFromSettings(settings) {
|
|
40
|
+
if (!settings.selfhostedRelayUrl?.trim()) return "";
|
|
41
|
+
const { host, port, tls, path } = relayConfigFromSettings(settings);
|
|
42
|
+
const scheme = tls ? "wss" : "ws";
|
|
43
|
+
const defaultPort = tls ? 443 : 80;
|
|
44
|
+
const authority = port === defaultPort ? host : `${host}:${port}`;
|
|
45
|
+
return `${scheme}://${authority}${path}`;
|
|
46
|
+
}
|
|
47
|
+
function controlRpcFactory(bus) {
|
|
48
|
+
return () => new NatsControlRpc(async () => {
|
|
49
|
+
if (!bus) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return bus.getRequestConnection();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function routingEnabled(settings) {
|
|
56
|
+
return settings.sandboxSelfhostedEnabled === true;
|
|
57
|
+
}
|
|
58
|
+
function wrapChannelABoxWithRouting(services, ids, established) {
|
|
59
|
+
const { db, settings, bus } = services;
|
|
60
|
+
const resolver = makeActiveBackendResolver({
|
|
61
|
+
workspaceId: ids.workspaceId,
|
|
62
|
+
defaultBackend: established.session,
|
|
63
|
+
defaultKind: established.backendId,
|
|
64
|
+
getSandbox: async (sandboxId) => {
|
|
65
|
+
const sandbox = await getSandbox(db, ids.workspaceId, sandboxId);
|
|
66
|
+
return sandbox ? { id: sandbox.id, kind: sandbox.kind, name: sandbox.name, enrollmentId: sandbox.enrollmentId } : null;
|
|
67
|
+
},
|
|
68
|
+
controlRpcFactory: controlRpcFactory(bus),
|
|
69
|
+
relay: relayConfigFromSettings(settings)
|
|
70
|
+
});
|
|
71
|
+
const proxy = new RoutingSandboxSession({
|
|
72
|
+
readPointer: async () => {
|
|
73
|
+
const pointer = await readActiveSandbox(db, ids.workspaceId, ids.sessionId);
|
|
74
|
+
return pointer ?? { activeSandboxId: null, activeEpoch: 0 };
|
|
75
|
+
},
|
|
76
|
+
resolveActiveBackend: resolver
|
|
77
|
+
});
|
|
78
|
+
return { ...established, session: proxy };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/sandbox/fleet.ts
|
|
82
|
+
async function buildFleetContextForSession(deps, ctx) {
|
|
83
|
+
const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
|
|
84
|
+
if (session.sandboxBackend === "none") {
|
|
85
|
+
throw new HTTPException(422, {
|
|
86
|
+
message: "this session has no sandbox (backend: none); the fleet is unavailable"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
accountId: ctx.accountId,
|
|
91
|
+
workspaceId: ctx.workspaceId,
|
|
92
|
+
sessionId: ctx.sessionId,
|
|
93
|
+
sessionBackend: session.sandboxBackend,
|
|
94
|
+
sessionGroupId: session.sandboxGroupId
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
98
|
+
function controlRpc(bus) {
|
|
99
|
+
return new NatsControlRpc2(async () => {
|
|
100
|
+
if (!bus) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return bus.getRequestConnection();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function probeEnrollment(services, workspaceId, enrollment) {
|
|
107
|
+
const { settings, bus } = services;
|
|
108
|
+
let probeResponded = false;
|
|
109
|
+
if (enrollment.status === "active") {
|
|
110
|
+
const session = new SelfhostedSession({
|
|
111
|
+
workspaceId,
|
|
112
|
+
agentId: enrollment.id,
|
|
113
|
+
controlRpc: controlRpc(bus),
|
|
114
|
+
relay: relayConfigFromSettings(settings),
|
|
115
|
+
timeoutMs: PROBE_TIMEOUT_MS
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
probeResponded = await session.ping();
|
|
119
|
+
} catch {
|
|
120
|
+
probeResponded = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const state = selfhostedLiveness({
|
|
124
|
+
enrollment: {
|
|
125
|
+
status: enrollment.status,
|
|
126
|
+
exposure: enrollment.exposure,
|
|
127
|
+
allowScreenControl: enrollment.allowScreenControl,
|
|
128
|
+
hasDisplay: enrollment.hasDisplay,
|
|
129
|
+
lastSeenAt: enrollment.lastSeenAt
|
|
130
|
+
},
|
|
131
|
+
probeResponded
|
|
132
|
+
});
|
|
133
|
+
return { liveness: state.state, consented: state.consented, hasDisplay: state.hasDisplay };
|
|
134
|
+
}
|
|
135
|
+
async function listFleet(services, ctx) {
|
|
136
|
+
const { db } = services;
|
|
137
|
+
const pointer = await readActiveSandbox2(db, ctx.workspaceId, ctx.sessionId) ?? {
|
|
138
|
+
activeSandboxId: null,
|
|
139
|
+
activeEpoch: 0
|
|
140
|
+
};
|
|
141
|
+
const entries = [];
|
|
142
|
+
const groupActive = pointer.activeSandboxId === null;
|
|
143
|
+
entries.push({
|
|
144
|
+
id: ctx.sessionGroupId,
|
|
145
|
+
kind: ctx.sessionBackend === "selfhosted" ? "selfhosted" : "modal",
|
|
146
|
+
name: "session sandbox",
|
|
147
|
+
liveness: "online",
|
|
148
|
+
active: groupActive,
|
|
149
|
+
isSessionGroup: true,
|
|
150
|
+
enrollmentId: null,
|
|
151
|
+
attachable: true
|
|
152
|
+
});
|
|
153
|
+
const sandboxes = await listSandboxes(db, ctx.workspaceId);
|
|
154
|
+
for (const sandbox of sandboxes) {
|
|
155
|
+
if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const enrollment = await getEnrollment(db, ctx.workspaceId, sandbox.enrollmentId);
|
|
159
|
+
const probe = enrollment ? await probeEnrollment(services, ctx.workspaceId, enrollment) : { liveness: "offline", consented: false, hasDisplay: false };
|
|
160
|
+
entries.push({
|
|
161
|
+
id: sandbox.id,
|
|
162
|
+
kind: "selfhosted",
|
|
163
|
+
name: sandbox.name,
|
|
164
|
+
liveness: probe.liveness,
|
|
165
|
+
active: pointer.activeSandboxId === sandbox.id,
|
|
166
|
+
isSessionGroup: false,
|
|
167
|
+
enrollmentId: sandbox.enrollmentId,
|
|
168
|
+
attachable: probe.liveness === "online",
|
|
169
|
+
consented: probe.consented,
|
|
170
|
+
hasDisplay: probe.hasDisplay,
|
|
171
|
+
lastSeenAt: enrollment?.lastSeenAt ?? null
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return { activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch, sandboxes: entries };
|
|
175
|
+
}
|
|
176
|
+
async function resolveTarget(services, ctx, target) {
|
|
177
|
+
if (target === ctx.sessionGroupId || target === "session" || target === "default") {
|
|
178
|
+
return { ok: true, targetSandboxId: null };
|
|
179
|
+
}
|
|
180
|
+
const sandbox = await getSandbox2(services.db, ctx.workspaceId, target);
|
|
181
|
+
if (!sandbox) {
|
|
182
|
+
return { ok: false, reason: `sandbox ${target} not found in this workspace` };
|
|
183
|
+
}
|
|
184
|
+
if (sandbox.kind === "selfhosted") {
|
|
185
|
+
if (!sandbox.enrollmentId) {
|
|
186
|
+
return { ok: false, reason: `selfhosted sandbox ${target} has no enrollment` };
|
|
187
|
+
}
|
|
188
|
+
const enrollment = await getEnrollment(services.db, ctx.workspaceId, sandbox.enrollmentId);
|
|
189
|
+
if (!enrollment) {
|
|
190
|
+
return { ok: false, reason: `enrollment for sandbox ${target} not found` };
|
|
191
|
+
}
|
|
192
|
+
const probe = await probeEnrollment(services, ctx.workspaceId, enrollment);
|
|
193
|
+
if (probe.liveness !== "online") {
|
|
194
|
+
return { ok: false, reason: `sandbox ${target} is ${probe.liveness}; cannot attach to a non-online machine` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, targetSandboxId: sandbox.id };
|
|
198
|
+
}
|
|
199
|
+
async function swapActiveSandbox(services, ctx, target, workingDir) {
|
|
200
|
+
const resolved = await resolveTarget(services, ctx, target);
|
|
201
|
+
if (!resolved.ok) {
|
|
202
|
+
const pointer2 = await readActiveSandbox2(services.db, ctx.workspaceId, ctx.sessionId) ?? {
|
|
203
|
+
activeSandboxId: null,
|
|
204
|
+
activeEpoch: 0
|
|
205
|
+
};
|
|
206
|
+
return { swapped: false, activeSandboxId: pointer2.activeSandboxId, activeEpoch: pointer2.activeEpoch, reason: resolved.reason };
|
|
207
|
+
}
|
|
208
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
209
|
+
const pointer2 = await readActiveSandbox2(services.db, ctx.workspaceId, ctx.sessionId) ?? {
|
|
210
|
+
activeSandboxId: null,
|
|
211
|
+
activeEpoch: 0
|
|
212
|
+
};
|
|
213
|
+
if (pointer2.activeSandboxId === resolved.targetSandboxId) {
|
|
214
|
+
return { swapped: true, activeSandboxId: pointer2.activeSandboxId, activeEpoch: pointer2.activeEpoch };
|
|
215
|
+
}
|
|
216
|
+
const result = await setActiveSandbox(services.db, {
|
|
217
|
+
accountId: ctx.accountId,
|
|
218
|
+
workspaceId: ctx.workspaceId,
|
|
219
|
+
sessionId: ctx.sessionId,
|
|
220
|
+
targetSandboxId: resolved.targetSandboxId,
|
|
221
|
+
expectedEpoch: pointer2.activeEpoch,
|
|
222
|
+
...workingDir !== void 0 ? { workingDir } : {}
|
|
223
|
+
});
|
|
224
|
+
if (result.swapped && result.pointer) {
|
|
225
|
+
return { swapped: true, activeSandboxId: result.pointer.activeSandboxId, activeEpoch: result.pointer.activeEpoch };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const pointer = await readActiveSandbox2(services.db, ctx.workspaceId, ctx.sessionId) ?? {
|
|
229
|
+
activeSandboxId: null,
|
|
230
|
+
activeEpoch: 0
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
swapped: false,
|
|
234
|
+
activeSandboxId: pointer.activeSandboxId,
|
|
235
|
+
activeEpoch: pointer.activeEpoch,
|
|
236
|
+
reason: "a concurrent swap won the epoch fence; re-read and retry"
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function runOnSandbox(services, ctx, target, op) {
|
|
240
|
+
const sandbox = await getSandbox2(services.db, ctx.workspaceId, target);
|
|
241
|
+
if (!sandbox) {
|
|
242
|
+
return { target, kind: op.kind, ok: false, reason: `sandbox ${target} not found in this workspace` };
|
|
243
|
+
}
|
|
244
|
+
if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
|
|
245
|
+
return {
|
|
246
|
+
target,
|
|
247
|
+
kind: op.kind,
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: `run_on routes one-off ops to enrolled selfhosted machines; ${sandbox.kind} targets are reached via the active sandbox (swap to it first)`
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const enrollment = await getEnrollment(services.db, ctx.workspaceId, sandbox.enrollmentId);
|
|
253
|
+
if (!enrollment || enrollment.status !== "active") {
|
|
254
|
+
return { target, kind: op.kind, ok: false, reason: `sandbox ${target} is not enrolled/active` };
|
|
255
|
+
}
|
|
256
|
+
const session = new SelfhostedSession({
|
|
257
|
+
workspaceId: ctx.workspaceId,
|
|
258
|
+
agentId: sandbox.enrollmentId,
|
|
259
|
+
controlRpc: controlRpc(services.bus),
|
|
260
|
+
relay: relayConfigFromSettings(services.settings)
|
|
261
|
+
});
|
|
262
|
+
try {
|
|
263
|
+
if (op.kind === "exec") {
|
|
264
|
+
const res = await session.exec({ cmd: op.cmd, ...op.workdir ? { workdir: op.workdir } : {} });
|
|
265
|
+
return { target, kind: "exec", ok: true, stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode };
|
|
266
|
+
}
|
|
267
|
+
if (op.kind === "read") {
|
|
268
|
+
const bytes = await session.readFile({ path: op.path });
|
|
269
|
+
return { target, kind: "read", ok: true, content: new TextDecoder().decode(bytes) };
|
|
270
|
+
}
|
|
271
|
+
const bytesWritten = await session.writeFile({ path: op.path, content: op.content });
|
|
272
|
+
return { target, kind: "write", ok: true, bytesWritten };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
275
|
+
return { target, kind: op.kind, ok: false, reason };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function provisionSandbox(services, ctx, input) {
|
|
279
|
+
if (input.kind === "selfhosted") {
|
|
280
|
+
const base = (services.settings.publicBaseUrl ?? "https://get.opengeni.ai").replace(/\/+$/, "");
|
|
281
|
+
return {
|
|
282
|
+
kind: "selfhosted",
|
|
283
|
+
instructions: "Share these instructions with a human operator. They install the OpenGeni agent on the machine, run `opengeni-agent enroll`, complete the device-flow at the verification URL (the loud whole-machine + screen-control consent), and the machine then appears here as an attachable selfhosted sandbox.",
|
|
284
|
+
// Install from THIS control plane's origin (not a hardcoded public CDN): the
|
|
285
|
+
// served install script is rewritten to pull the per-SHA agent baked into
|
|
286
|
+
// this exact deployment (see apps/api/src/routes/install.ts), so a deployed
|
|
287
|
+
// env is self-contained and a private/air-gapped one works with no public DNS.
|
|
288
|
+
installCommandUnix: `curl -fsSL ${base}/install.sh | sh`,
|
|
289
|
+
installCommandWindows: `irm ${base}/install.ps1 | iex`,
|
|
290
|
+
verificationUri: `${base}/device`,
|
|
291
|
+
note: "Whole-machine access requires explicit human consent in the device-flow web page; the agent cannot self-consent."
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const { createSandbox } = await import("@opengeni/db");
|
|
295
|
+
const sandbox = await createSandbox(services.db, {
|
|
296
|
+
accountId: ctx.accountId,
|
|
297
|
+
workspaceId: ctx.workspaceId,
|
|
298
|
+
kind: "modal",
|
|
299
|
+
name: input.name?.trim() || "modal-box"
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
kind: "modal",
|
|
303
|
+
sandbox,
|
|
304
|
+
note: "A named Modal sandbox record was created. Its box is materialized when first swapped-to; the session's own group box remains the default until then."
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/access/index.ts
|
|
309
|
+
import { verifyDelegatedAccessToken } from "@opengeni/contracts";
|
|
310
|
+
import {
|
|
311
|
+
bootstrapWorkspace,
|
|
312
|
+
ensureManagedAccessForUser,
|
|
313
|
+
findActiveApiKeyByHash,
|
|
314
|
+
getWorkspaceGrant,
|
|
315
|
+
requireWorkspace
|
|
316
|
+
} from "@opengeni/db";
|
|
317
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
318
|
+
var bearerPrefix = "Bearer ";
|
|
319
|
+
async function requireAccessContext(c, deps) {
|
|
320
|
+
const context = await resolveAccessContext(c, deps);
|
|
321
|
+
if (!context) {
|
|
322
|
+
throw new HTTPException2(401, { message: "authentication required" });
|
|
323
|
+
}
|
|
324
|
+
return context;
|
|
325
|
+
}
|
|
326
|
+
async function requireAccessGrant(c, deps, workspaceId, permission) {
|
|
327
|
+
const context = await requireAccessContext(c, deps);
|
|
328
|
+
const grant = context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ?? await getWorkspaceGrant(deps.db, context.subjectId, workspaceId);
|
|
329
|
+
if (!grant) {
|
|
330
|
+
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
331
|
+
if (!workspace) {
|
|
332
|
+
throw new HTTPException2(404, { message: "workspace not found" });
|
|
333
|
+
}
|
|
334
|
+
throw new HTTPException2(403, { message: "workspace access denied" });
|
|
335
|
+
}
|
|
336
|
+
if (permission) {
|
|
337
|
+
requirePermission(grant, permission);
|
|
338
|
+
}
|
|
339
|
+
return grant;
|
|
340
|
+
}
|
|
341
|
+
function requirePermission(grant, permission) {
|
|
342
|
+
if (!hasPermission(grant.permissions, permission)) {
|
|
343
|
+
throw new HTTPException2(403, { message: `missing permission: ${permission}` });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function hasPermission(permissions, permission) {
|
|
347
|
+
return permissions.includes(permission) || permissions.includes("workspace:admin");
|
|
348
|
+
}
|
|
349
|
+
async function resolveAccessContext(c, deps) {
|
|
350
|
+
if (deps.settings.productAccessMode === "local") {
|
|
351
|
+
return await bootstrapWorkspace(deps.db, {
|
|
352
|
+
accountExternalSource: "opengeni:local",
|
|
353
|
+
accountExternalId: "default",
|
|
354
|
+
accountName: "Local",
|
|
355
|
+
workspaceExternalSource: "opengeni:local",
|
|
356
|
+
workspaceExternalId: "default",
|
|
357
|
+
workspaceName: "Local",
|
|
358
|
+
subjectId: "dev",
|
|
359
|
+
subjectLabel: "Local dev"
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (deps.settings.productAccessMode === "configured") {
|
|
363
|
+
const delegated = await delegatedAccessContext(c, deps, "configured");
|
|
364
|
+
if (delegated) {
|
|
365
|
+
return delegated;
|
|
366
|
+
}
|
|
367
|
+
if (deps.settings.delegationSecret) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
return await bootstrapWorkspace(deps.db, {
|
|
371
|
+
accountExternalSource: "opengeni:configured",
|
|
372
|
+
accountExternalId: "default",
|
|
373
|
+
accountName: "Configured",
|
|
374
|
+
workspaceExternalSource: "opengeni:configured",
|
|
375
|
+
workspaceExternalId: "default",
|
|
376
|
+
workspaceName: "Configured",
|
|
377
|
+
subjectId: configuredSubject(c),
|
|
378
|
+
subjectLabel: "Configured key"
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const bearer = bearerToken(c);
|
|
382
|
+
if (bearer) {
|
|
383
|
+
const delegated = await delegatedAccessContext(c, deps, "managed", bearer);
|
|
384
|
+
if (delegated) {
|
|
385
|
+
return delegated;
|
|
386
|
+
}
|
|
387
|
+
const apiKey = await findActiveApiKeyByHash(deps.db, await sha256Hex(bearer));
|
|
388
|
+
if (apiKey) {
|
|
389
|
+
const accountPermissions = apiKey.workspaceId ? apiKey.permissions.filter((permission) => permission === "billing:read" || permission === "billing:manage") : apiKey.permissions;
|
|
390
|
+
return {
|
|
391
|
+
mode: "managed",
|
|
392
|
+
subjectId: `api_key:${apiKey.id}`,
|
|
393
|
+
subjectLabel: apiKey.name,
|
|
394
|
+
accountGrants: [{
|
|
395
|
+
accountId: apiKey.accountId,
|
|
396
|
+
subjectId: `api_key:${apiKey.id}`,
|
|
397
|
+
subjectLabel: apiKey.name,
|
|
398
|
+
permissions: accountPermissions
|
|
399
|
+
}],
|
|
400
|
+
workspaceGrants: apiKey.workspaceId ? [{
|
|
401
|
+
workspaceId: apiKey.workspaceId,
|
|
402
|
+
accountId: apiKey.accountId,
|
|
403
|
+
subjectId: `api_key:${apiKey.id}`,
|
|
404
|
+
subjectLabel: apiKey.name,
|
|
405
|
+
permissions: apiKey.permissions
|
|
406
|
+
}] : [],
|
|
407
|
+
defaultAccountId: apiKey.accountId,
|
|
408
|
+
defaultWorkspaceId: apiKey.workspaceId
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (deps.managedAuth) {
|
|
413
|
+
const session = await deps.managedAuth.api.getSession({ headers: c.req.raw.headers });
|
|
414
|
+
if (session?.user) {
|
|
415
|
+
return await ensureManagedAccessForUser(deps.db, {
|
|
416
|
+
userId: session.user.id,
|
|
417
|
+
email: session.user.email,
|
|
418
|
+
name: session.user.name
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
async function delegatedAccessContext(c, deps, mode, token = bearerToken(c)) {
|
|
425
|
+
if (!token || !deps.settings.delegationSecret) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const payload = await verifyDelegatedAccessToken(deps.settings.delegationSecret, token);
|
|
429
|
+
if (!payload) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
mode,
|
|
434
|
+
subjectId: payload.subjectId,
|
|
435
|
+
...payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {},
|
|
436
|
+
accountGrants: [{
|
|
437
|
+
accountId: payload.accountId,
|
|
438
|
+
subjectId: payload.subjectId,
|
|
439
|
+
...payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {},
|
|
440
|
+
permissions: payload.permissions
|
|
441
|
+
}],
|
|
442
|
+
workspaceGrants: [{
|
|
443
|
+
workspaceId: payload.workspaceId,
|
|
444
|
+
accountId: payload.accountId,
|
|
445
|
+
subjectId: payload.subjectId,
|
|
446
|
+
...payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {},
|
|
447
|
+
permissions: payload.permissions,
|
|
448
|
+
// sessionId is worker-asserted (HMAC-signed token claim), not agent
|
|
449
|
+
// controlled; it scopes session-bound MCP tools such as goal management.
|
|
450
|
+
metadata: { delegated: true, ...payload.sessionId ? { sessionId: payload.sessionId } : {} }
|
|
451
|
+
}],
|
|
452
|
+
defaultAccountId: payload.accountId,
|
|
453
|
+
defaultWorkspaceId: payload.workspaceId
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function configuredSubject(c) {
|
|
457
|
+
const header = c.req.header("x-opengeni-subject");
|
|
458
|
+
return header && header.trim().length > 0 ? `configured:${header.trim()}` : "configured:key";
|
|
459
|
+
}
|
|
460
|
+
function bearerToken(c) {
|
|
461
|
+
const authorization = c.req.header("authorization");
|
|
462
|
+
return authorization?.startsWith(bearerPrefix) ? authorization.slice(bearerPrefix.length) : null;
|
|
463
|
+
}
|
|
464
|
+
async function sha256Hex(value) {
|
|
465
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
466
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/billing/limits.ts
|
|
470
|
+
import { configuredStaticUsageLimits } from "@opengeni/config";
|
|
471
|
+
import {
|
|
472
|
+
countActiveApiKeysForWorkspace,
|
|
473
|
+
countScheduledTasksForWorkspace,
|
|
474
|
+
countWorkspacesForAccount,
|
|
475
|
+
getBillingBalance,
|
|
476
|
+
isCodexBilledTurn,
|
|
477
|
+
recordUsageEvent,
|
|
478
|
+
sumUsageQuantity
|
|
479
|
+
} from "@opengeni/db";
|
|
480
|
+
import { HTTPException as HTTPException3 } from "hono/http-exception";
|
|
481
|
+
async function requireLimit(deps, input) {
|
|
482
|
+
const decision = await checkLimit(deps, input);
|
|
483
|
+
if (decision.allowed) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
throw new HTTPException3(decision.code === "insufficient_credits" ? 402 : 429, { message: decision.message });
|
|
487
|
+
}
|
|
488
|
+
async function checkLimit(deps, input) {
|
|
489
|
+
const codexBilled = input.workspaceId ? await isCodexBilledTurn({ db: deps.db, settings: deps.settings, workspaceId: input.workspaceId, model: input.model }) : false;
|
|
490
|
+
const creditDecision = await checkCreditBalance(deps, input, codexBilled);
|
|
491
|
+
if (!creditDecision.allowed) {
|
|
492
|
+
return creditDecision;
|
|
493
|
+
}
|
|
494
|
+
if (deps.settings.usageLimitsMode !== "static" && deps.settings.usageLimitsMode !== "managed") {
|
|
495
|
+
return { allowed: true };
|
|
496
|
+
}
|
|
497
|
+
return await checkStaticCaps(deps, input, codexBilled);
|
|
498
|
+
}
|
|
499
|
+
async function checkCreditBalance(deps, input, codexBilled) {
|
|
500
|
+
if (codexBilled) {
|
|
501
|
+
return { allowed: true };
|
|
502
|
+
}
|
|
503
|
+
if (!usesCreditLimits(deps) || !isCostlyAction(input.action)) {
|
|
504
|
+
return { allowed: true };
|
|
505
|
+
}
|
|
506
|
+
const balance = await getBillingBalance(deps.db, input.accountId);
|
|
507
|
+
if (balance.balanceMicros > 0) {
|
|
508
|
+
return { allowed: true };
|
|
509
|
+
}
|
|
510
|
+
return { allowed: false, code: "insufficient_credits", message: "insufficient OpenGeni credits" };
|
|
511
|
+
}
|
|
512
|
+
async function checkStaticCaps(deps, input, codexBilled) {
|
|
513
|
+
const limits = configuredStaticUsageLimits(deps.settings);
|
|
514
|
+
if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !codexBilled) {
|
|
515
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
516
|
+
accountId: input.accountId,
|
|
517
|
+
eventType: "model.cost",
|
|
518
|
+
since: startOfUtcMonth()
|
|
519
|
+
});
|
|
520
|
+
if (used >= limits.maxMonthlyCostMicrosPerAccount) {
|
|
521
|
+
return blocked("max_monthly_cost_micros_per_account", `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
switch (input.action) {
|
|
525
|
+
case "workspace:create": {
|
|
526
|
+
if (!limits.maxWorkspacesPerAccount) {
|
|
527
|
+
return { allowed: true };
|
|
528
|
+
}
|
|
529
|
+
const count = await countWorkspacesForAccount(deps.db, input.accountId);
|
|
530
|
+
return count < limits.maxWorkspacesPerAccount ? { allowed: true } : blocked("max_workspaces_per_account", `workspace limit reached (${limits.maxWorkspacesPerAccount})`);
|
|
531
|
+
}
|
|
532
|
+
case "api_key:create": {
|
|
533
|
+
if (!limits.maxApiKeysPerWorkspace || !input.workspaceId) {
|
|
534
|
+
return { allowed: true };
|
|
535
|
+
}
|
|
536
|
+
const count = await countActiveApiKeysForWorkspace(deps.db, input.workspaceId);
|
|
537
|
+
return count < limits.maxApiKeysPerWorkspace ? { allowed: true } : blocked("max_api_keys_per_workspace", `API key limit reached (${limits.maxApiKeysPerWorkspace})`);
|
|
538
|
+
}
|
|
539
|
+
case "schedule:create": {
|
|
540
|
+
if (!limits.maxSchedulesPerWorkspace || !input.workspaceId) {
|
|
541
|
+
return { allowed: true };
|
|
542
|
+
}
|
|
543
|
+
const count = await countScheduledTasksForWorkspace(deps.db, input.workspaceId);
|
|
544
|
+
return count < limits.maxSchedulesPerWorkspace ? { allowed: true } : blocked("max_schedules_per_workspace", `scheduled task limit reached (${limits.maxSchedulesPerWorkspace})`);
|
|
545
|
+
}
|
|
546
|
+
case "file:upload": {
|
|
547
|
+
if (!limits.maxFileUploadBytes || !input.quantity) {
|
|
548
|
+
return { allowed: true };
|
|
549
|
+
}
|
|
550
|
+
return input.quantity <= limits.maxFileUploadBytes ? { allowed: true } : blocked("max_file_upload_bytes", `file upload exceeds static limit of ${limits.maxFileUploadBytes} bytes`);
|
|
551
|
+
}
|
|
552
|
+
case "agent_run:create": {
|
|
553
|
+
if (!limits.maxMonthlyAgentRunsPerWorkspace || !input.workspaceId) {
|
|
554
|
+
return { allowed: true };
|
|
555
|
+
}
|
|
556
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
557
|
+
workspaceId: input.workspaceId,
|
|
558
|
+
eventType: "agent_run.created",
|
|
559
|
+
since: startOfUtcMonth()
|
|
560
|
+
});
|
|
561
|
+
const requested = input.quantity ?? 0;
|
|
562
|
+
return used + requested <= limits.maxMonthlyAgentRunsPerWorkspace ? { allowed: true } : blocked("max_monthly_agent_runs_per_workspace", `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`);
|
|
563
|
+
}
|
|
564
|
+
case "tokens:consume": {
|
|
565
|
+
if (codexBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
|
|
566
|
+
return { allowed: true };
|
|
567
|
+
}
|
|
568
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
569
|
+
workspaceId: input.workspaceId,
|
|
570
|
+
eventType: "model.tokens",
|
|
571
|
+
since: startOfUtcMonth()
|
|
572
|
+
});
|
|
573
|
+
const requested = input.quantity ?? 0;
|
|
574
|
+
return used + requested <= limits.maxMonthlyTokensPerWorkspace ? { allowed: true } : blocked("max_monthly_tokens_per_workspace", `monthly token limit reached (${limits.maxMonthlyTokensPerWorkspace})`);
|
|
575
|
+
}
|
|
576
|
+
case "document:index": {
|
|
577
|
+
if (!limits.maxDocumentIndexedChunksPerWorkspace || !input.workspaceId) {
|
|
578
|
+
return { allowed: true };
|
|
579
|
+
}
|
|
580
|
+
const used = await sumUsageQuantity(deps.db, {
|
|
581
|
+
workspaceId: input.workspaceId,
|
|
582
|
+
eventType: "document.indexed",
|
|
583
|
+
since: startOfUtcMonth()
|
|
584
|
+
});
|
|
585
|
+
const requested = input.quantity ?? 0;
|
|
586
|
+
return used + requested <= limits.maxDocumentIndexedChunksPerWorkspace ? { allowed: true } : blocked("max_document_indexed_chunks_per_workspace", `monthly document indexing limit reached (${limits.maxDocumentIndexedChunksPerWorkspace} chunks)`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function recordWorkspaceUsage(deps, input) {
|
|
591
|
+
await recordUsageEvent(deps.db, {
|
|
592
|
+
accountId: input.accountId,
|
|
593
|
+
workspaceId: input.workspaceId,
|
|
594
|
+
subjectId: input.subjectId ?? null,
|
|
595
|
+
eventType: input.eventType,
|
|
596
|
+
quantity: input.quantity,
|
|
597
|
+
unit: input.unit,
|
|
598
|
+
sourceResourceType: input.sourceResourceType,
|
|
599
|
+
sourceResourceId: input.sourceResourceId,
|
|
600
|
+
idempotencyKey: input.idempotencyKey
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function usesCreditLimits(deps) {
|
|
604
|
+
return deps.settings.billingMode === "stripe" || deps.settings.usageLimitsMode === "managed";
|
|
605
|
+
}
|
|
606
|
+
function isCostlyAction(action) {
|
|
607
|
+
return action === "agent_run:create" || action === "tokens:consume" || action === "file:upload" || action === "document:index";
|
|
608
|
+
}
|
|
609
|
+
function blocked(code, message) {
|
|
610
|
+
return { allowed: false, code, message };
|
|
611
|
+
}
|
|
612
|
+
function startOfUtcMonth() {
|
|
613
|
+
const now = /* @__PURE__ */ new Date();
|
|
614
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/domain/capabilities.ts
|
|
618
|
+
import { readdir, readFile } from "fs/promises";
|
|
619
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
620
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
621
|
+
import { environmentsEncryptionKeyBytes as environmentsEncryptionKeyBytes2 } from "@opengeni/config";
|
|
622
|
+
import {
|
|
623
|
+
CapabilityCatalogItem
|
|
624
|
+
} from "@opengeni/contracts";
|
|
625
|
+
import {
|
|
626
|
+
decryptEnvironmentValue,
|
|
627
|
+
decryptedCapabilityHeaders,
|
|
628
|
+
disableCapabilityInstallation,
|
|
629
|
+
enableCapabilityInstallation,
|
|
630
|
+
enablePackInstallation,
|
|
631
|
+
encryptEnvironmentValue,
|
|
632
|
+
getCapabilityCatalogItem,
|
|
633
|
+
getCapabilityInstallation,
|
|
634
|
+
getPackInstallation,
|
|
635
|
+
getStoredCapabilityHeaderCiphertext,
|
|
636
|
+
getWorkspaceEnvironment as getWorkspaceEnvironment2,
|
|
637
|
+
listCapabilityCatalogItems,
|
|
638
|
+
listCapabilityInstallations,
|
|
639
|
+
listEnabledMcpCapabilityServers,
|
|
640
|
+
listPackInstallations as listPackInstallations2,
|
|
641
|
+
mcpServerIdForCapability,
|
|
642
|
+
updatePackInstallationStatus,
|
|
643
|
+
upsertCapabilityCatalogItem
|
|
644
|
+
} from "@opengeni/db";
|
|
645
|
+
import { HTTPException as HTTPException6 } from "hono/http-exception";
|
|
646
|
+
|
|
647
|
+
// src/domain/environments.ts
|
|
648
|
+
import { environmentsEncryptionKeyBytes } from "@opengeni/config";
|
|
649
|
+
import {
|
|
650
|
+
getWorkspaceEnvironment,
|
|
651
|
+
recordAuditEvent
|
|
652
|
+
} from "@opengeni/db";
|
|
653
|
+
import { HTTPException as HTTPException4 } from "hono/http-exception";
|
|
654
|
+
var MAX_ENVIRONMENTS_PER_WORKSPACE = 25;
|
|
655
|
+
var MAX_VARIABLES_PER_ENVIRONMENT = 100;
|
|
656
|
+
var reservedExactNames = /* @__PURE__ */ new Set([
|
|
657
|
+
"HOME",
|
|
658
|
+
"PATH",
|
|
659
|
+
"SHELL",
|
|
660
|
+
"USER",
|
|
661
|
+
"LOGNAME",
|
|
662
|
+
"TMPDIR",
|
|
663
|
+
"IFS",
|
|
664
|
+
"ENV",
|
|
665
|
+
"BASH_ENV",
|
|
666
|
+
"NODE_OPTIONS",
|
|
667
|
+
"PYTHONPATH",
|
|
668
|
+
"PYTHONSTARTUP",
|
|
669
|
+
"PERL5OPT",
|
|
670
|
+
"PERL5LIB",
|
|
671
|
+
"GH_TOKEN",
|
|
672
|
+
"GITHUB_TOKEN",
|
|
673
|
+
"GIT_ASKPASS",
|
|
674
|
+
"GIT_TERMINAL_PROMPT"
|
|
675
|
+
]);
|
|
676
|
+
var reservedPrefixes = [
|
|
677
|
+
"OPENGENI_",
|
|
678
|
+
"GIT_CONFIG_",
|
|
679
|
+
"GIT_AUTHOR_",
|
|
680
|
+
"GIT_COMMITTER_",
|
|
681
|
+
"LD_",
|
|
682
|
+
"DYLD_"
|
|
683
|
+
];
|
|
684
|
+
function assertAllowedEnvironmentVariableName(name) {
|
|
685
|
+
if (reservedExactNames.has(name) || reservedPrefixes.some((prefix) => name.startsWith(prefix))) {
|
|
686
|
+
throw new HTTPException4(422, { message: `reserved environment variable name: ${name}` });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function requireEnvironmentEncryption(settings) {
|
|
690
|
+
const key = environmentsEncryptionKeyBytes(settings);
|
|
691
|
+
if (!key) {
|
|
692
|
+
throw new HTTPException4(503, { message: "workspace environments require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY" });
|
|
693
|
+
}
|
|
694
|
+
return key;
|
|
695
|
+
}
|
|
696
|
+
async function requireEnvironmentForApi(db, workspaceId, environmentId) {
|
|
697
|
+
const environment = await getWorkspaceEnvironment(db, workspaceId, environmentId);
|
|
698
|
+
if (!environment) {
|
|
699
|
+
throw new HTTPException4(404, { message: "environment not found" });
|
|
700
|
+
}
|
|
701
|
+
return environment;
|
|
702
|
+
}
|
|
703
|
+
async function validateEnvironmentAttachment(deps, grant, workspaceId, environmentId, options = {}) {
|
|
704
|
+
requireEnvironmentEncryption(deps.settings);
|
|
705
|
+
if (!options.preauthorized) {
|
|
706
|
+
requirePermission(grant, "environments:use");
|
|
707
|
+
}
|
|
708
|
+
const environment = await getWorkspaceEnvironment(deps.db, workspaceId, environmentId);
|
|
709
|
+
if (!environment) {
|
|
710
|
+
throw new HTTPException4(422, { message: "unknown environmentId" });
|
|
711
|
+
}
|
|
712
|
+
return environment;
|
|
713
|
+
}
|
|
714
|
+
async function recordEnvironmentAuditEvent(db, input) {
|
|
715
|
+
await recordAuditEvent(db, {
|
|
716
|
+
accountId: input.grant.accountId,
|
|
717
|
+
workspaceId: input.grant.workspaceId,
|
|
718
|
+
subjectId: input.grant.subjectId,
|
|
719
|
+
action: input.action,
|
|
720
|
+
targetType: "workspace_environment",
|
|
721
|
+
targetId: input.environmentId,
|
|
722
|
+
metadata: {
|
|
723
|
+
environmentId: input.environmentId,
|
|
724
|
+
...input.variableName ? { name: input.variableName } : {}
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/domain/packs.ts
|
|
730
|
+
import {
|
|
731
|
+
CapabilityPack
|
|
732
|
+
} from "@opengeni/contracts";
|
|
733
|
+
import { getWorkspacePack, listPackInstallations, listWorkspacePacks } from "@opengeni/db";
|
|
734
|
+
import { HTTPException as HTTPException5 } from "hono/http-exception";
|
|
735
|
+
var MARKETING_SOCIAL_PACK_ID = "marketing-social-daily-analysis";
|
|
736
|
+
var marketingSocialPack = {
|
|
737
|
+
id: MARKETING_SOCIAL_PACK_ID,
|
|
738
|
+
name: "Marketing social daily analysis",
|
|
739
|
+
description: "Connect social accounts, attach marketing knowledge, and schedule agents to produce daily media performance analysis.",
|
|
740
|
+
role: "marketing",
|
|
741
|
+
category: "social-media",
|
|
742
|
+
version: "0.1.0",
|
|
743
|
+
// Built-in packs deliberately declare no sandboxImage and no skills: the
|
|
744
|
+
// worker's pack-runtime resolution only reads manifest-registered packs
|
|
745
|
+
// (see apps/worker/src/activities/packs.ts), and a test enforces this.
|
|
746
|
+
skills: [],
|
|
747
|
+
tools: [
|
|
748
|
+
{ kind: "mcp", id: "opengeni" },
|
|
749
|
+
{ kind: "mcp", id: "docs" }
|
|
750
|
+
],
|
|
751
|
+
connectors: [
|
|
752
|
+
{
|
|
753
|
+
id: "x",
|
|
754
|
+
name: "X",
|
|
755
|
+
category: "social-media",
|
|
756
|
+
authModel: "oauth2_authorization_code_pkce",
|
|
757
|
+
providers: ["x"],
|
|
758
|
+
scopes: ["tweet.read", "users.read", "offline.access"],
|
|
759
|
+
required: false,
|
|
760
|
+
metadata: {
|
|
761
|
+
docs: "https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code"
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "linkedin",
|
|
766
|
+
name: "LinkedIn",
|
|
767
|
+
category: "social-media",
|
|
768
|
+
authModel: "oauth2_authorization_code",
|
|
769
|
+
providers: ["linkedin"],
|
|
770
|
+
scopes: ["r_organization_social", "rw_organization_admin"],
|
|
771
|
+
required: false,
|
|
772
|
+
metadata: {
|
|
773
|
+
docs: "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/community-management-overview"
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
id: "instagram",
|
|
778
|
+
name: "Instagram",
|
|
779
|
+
category: "social-media",
|
|
780
|
+
authModel: "oauth2_authorization_code",
|
|
781
|
+
providers: ["instagram", "facebook"],
|
|
782
|
+
scopes: ["instagram_basic", "instagram_manage_insights", "pages_read_engagement", "pages_show_list"],
|
|
783
|
+
required: false,
|
|
784
|
+
metadata: {
|
|
785
|
+
docs: "https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/"
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
id: "tiktok",
|
|
790
|
+
name: "TikTok",
|
|
791
|
+
category: "social-media",
|
|
792
|
+
authModel: "oauth2_authorization_code",
|
|
793
|
+
providers: ["tiktok"],
|
|
794
|
+
scopes: ["user.info.basic", "video.list"],
|
|
795
|
+
required: false,
|
|
796
|
+
metadata: {
|
|
797
|
+
docs: "https://developers.tiktok.com/doc/tiktok-api-v2-introduction/"
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: "youtube",
|
|
802
|
+
name: "YouTube",
|
|
803
|
+
category: "social-media",
|
|
804
|
+
authModel: "oauth2_authorization_code",
|
|
805
|
+
providers: ["youtube"],
|
|
806
|
+
scopes: ["https://www.googleapis.com/auth/youtube.readonly", "https://www.googleapis.com/auth/yt-analytics.readonly"],
|
|
807
|
+
required: false,
|
|
808
|
+
metadata: {
|
|
809
|
+
docs: "https://developers.google.com/youtube/v3"
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
],
|
|
813
|
+
knowledge: [
|
|
814
|
+
{
|
|
815
|
+
type: "document_base",
|
|
816
|
+
id: "marketing-playbook",
|
|
817
|
+
name: "Marketing playbook",
|
|
818
|
+
description: "Optional workspace document base with brand voice, campaign calendars, audience research, and reporting rules.",
|
|
819
|
+
required: false
|
|
820
|
+
}
|
|
821
|
+
],
|
|
822
|
+
scheduledTaskTemplates: [
|
|
823
|
+
{
|
|
824
|
+
id: "daily-social-analysis",
|
|
825
|
+
name: "Daily social analysis",
|
|
826
|
+
description: "Review the latest social posts and account signals every day.",
|
|
827
|
+
defaultSchedule: {
|
|
828
|
+
type: "calendar",
|
|
829
|
+
timeZone: "UTC",
|
|
830
|
+
hour: 9,
|
|
831
|
+
minute: 0
|
|
832
|
+
},
|
|
833
|
+
defaultRunMode: "new_session_per_run",
|
|
834
|
+
defaultOverlapPolicy: "skip"
|
|
835
|
+
}
|
|
836
|
+
],
|
|
837
|
+
metadata: {
|
|
838
|
+
skill: "social-media-marketing",
|
|
839
|
+
firstPartyMcpTools: [
|
|
840
|
+
"social_connections_list",
|
|
841
|
+
"social_posts_recent",
|
|
842
|
+
"social_daily_analysis_context"
|
|
843
|
+
]
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
var packs = [marketingSocialPack];
|
|
847
|
+
function listCapabilityPacks() {
|
|
848
|
+
return packs;
|
|
849
|
+
}
|
|
850
|
+
function getCapabilityPack(packId) {
|
|
851
|
+
return packs.find((pack) => pack.id === packId) ?? null;
|
|
852
|
+
}
|
|
853
|
+
function isBuiltInCapabilityPack(packId) {
|
|
854
|
+
return getCapabilityPack(packId) !== null;
|
|
855
|
+
}
|
|
856
|
+
async function listWorkspaceCapabilityPacks(db, workspaceId) {
|
|
857
|
+
const registered = await listWorkspacePacks(db, workspaceId);
|
|
858
|
+
const builtInIds = new Set(packs.map((pack) => pack.id));
|
|
859
|
+
const registeredPacks = registered.filter((registration) => !builtInIds.has(registration.pack.id)).flatMap((registration) => {
|
|
860
|
+
const parsed = CapabilityPack.safeParse(registration.pack);
|
|
861
|
+
return parsed.success ? [parsed.data] : [];
|
|
862
|
+
});
|
|
863
|
+
return [...packs, ...registeredPacks];
|
|
864
|
+
}
|
|
865
|
+
async function resolveCapabilityPack(db, workspaceId, packId) {
|
|
866
|
+
const builtIn = getCapabilityPack(packId);
|
|
867
|
+
if (builtIn) {
|
|
868
|
+
return builtIn;
|
|
869
|
+
}
|
|
870
|
+
const registration = await getWorkspacePack(db, workspaceId, packId);
|
|
871
|
+
if (!registration) {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
const parsed = CapabilityPack.safeParse(registration.pack);
|
|
875
|
+
return parsed.success ? parsed.data : null;
|
|
876
|
+
}
|
|
877
|
+
async function assertPackSandboxImageCompatible(db, workspaceId, pack) {
|
|
878
|
+
if (!pack.sandboxImage) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const installations = await listPackInstallations(db, workspaceId);
|
|
882
|
+
for (const installation of installations) {
|
|
883
|
+
if (installation.status !== "active" || installation.packId === pack.id) {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const other = await resolveCapabilityPack(db, workspaceId, installation.packId);
|
|
887
|
+
if (other?.sandboxImage) {
|
|
888
|
+
throw new HTTPException5(409, {
|
|
889
|
+
message: `pack ${pack.id} declares a sandbox image, but enabled pack ${other.id} already declares one; only one enabled pack per workspace may declare sandboxImage \u2014 disable ${other.id} first`
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function buildMarketingDailyAnalysisAgentConfig(input) {
|
|
895
|
+
const connectionIds = input.connections.map((connection) => connection.id);
|
|
896
|
+
return {
|
|
897
|
+
prompt: marketingDailyAnalysisPrompt({
|
|
898
|
+
connections: input.connections,
|
|
899
|
+
documentBaseIds: input.documentBaseIds,
|
|
900
|
+
...input.promptInstructions ? { promptInstructions: input.promptInstructions } : {}
|
|
901
|
+
}),
|
|
902
|
+
resources: [],
|
|
903
|
+
tools: marketingSocialPack.tools,
|
|
904
|
+
metadata: {
|
|
905
|
+
packId: MARKETING_SOCIAL_PACK_ID,
|
|
906
|
+
packTemplateId: "daily-social-analysis",
|
|
907
|
+
socialConnectionIds: connectionIds,
|
|
908
|
+
documentBaseIds: input.documentBaseIds,
|
|
909
|
+
analysisWindowHours: 24
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function marketingDailyAnalysisPrompt(input) {
|
|
914
|
+
const connectionLines = input.connections.map((connection) => {
|
|
915
|
+
return `- ${connection.provider}: ${connection.accountHandle} (${connection.id})`;
|
|
916
|
+
}).join("\n");
|
|
917
|
+
const knowledgeLine = input.documentBaseIds.length > 0 ? `Use these document base IDs for brand/campaign knowledge through the docs MCP: ${input.documentBaseIds.join(", ")}.` : "No document base IDs were selected; rely only on social context returned by tools.";
|
|
918
|
+
const extra = input.promptInstructions ? `
|
|
919
|
+
Additional operator instructions:
|
|
920
|
+
${input.promptInstructions.trim()}
|
|
921
|
+
` : "";
|
|
922
|
+
return [
|
|
923
|
+
"Run the daily social media analysis for the selected accounts.",
|
|
924
|
+
"",
|
|
925
|
+
"First call the OpenGeni MCP tool social_daily_analysis_context with the selected connection IDs and a 24 hour analysis window. Use social_posts_recent only if you need a narrower follow-up query.",
|
|
926
|
+
knowledgeLine,
|
|
927
|
+
"",
|
|
928
|
+
"Selected accounts:",
|
|
929
|
+
connectionLines,
|
|
930
|
+
extra,
|
|
931
|
+
"Produce a concise report with these sections: executive summary, notable account changes, winning posts, underperforming posts, audience and content signals, recommended actions for the next 24 hours, and data gaps.",
|
|
932
|
+
"Use only metrics and posts returned by tools or document search. Do not invent metrics, posts, or account capabilities."
|
|
933
|
+
].filter(Boolean).join("\n");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/domain/capabilities.ts
|
|
937
|
+
var officialMcpRegistryUrl = "https://registry.modelcontextprotocol.io";
|
|
938
|
+
var firstPartyMcpServerIds = /* @__PURE__ */ new Set(["opengeni", "files", "docs"]);
|
|
939
|
+
var mcpRegistryFetchTimeoutMs = 15e3;
|
|
940
|
+
var mcpRegistryMaxPages = 3;
|
|
941
|
+
var mcpCapabilityProbeTimeoutMs = 15e3;
|
|
942
|
+
var maxMcpCredentialHeaders = 16;
|
|
943
|
+
var maxMcpCredentialHeaderValueLength = 4096;
|
|
944
|
+
var mcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
945
|
+
async function buildCapabilityCatalog(input) {
|
|
946
|
+
const [
|
|
947
|
+
persistedItems,
|
|
948
|
+
capabilityInstallations,
|
|
949
|
+
packInstallations,
|
|
950
|
+
workspacePacks,
|
|
951
|
+
bundledSkills
|
|
952
|
+
] = await Promise.all([
|
|
953
|
+
listCapabilityCatalogItems(input.db, input.workspaceId),
|
|
954
|
+
listCapabilityInstallations(input.db, input.workspaceId),
|
|
955
|
+
listPackInstallations2(input.db, input.workspaceId),
|
|
956
|
+
listWorkspaceCapabilityPacks(input.db, input.workspaceId),
|
|
957
|
+
discoverBundledSkills()
|
|
958
|
+
]);
|
|
959
|
+
const capabilityInstallationById = new Map(capabilityInstallations.map((installation) => [installation.capabilityId, installation]));
|
|
960
|
+
const activePackIds = new Set(packInstallations.filter((installation) => installation.status === "active").map((installation) => installation.packId));
|
|
961
|
+
const builtInPackIds = new Set(listCapabilityPacks().map((pack) => pack.id));
|
|
962
|
+
const builtIns = [
|
|
963
|
+
...workspacePacks.map((pack) => packCatalogItem(pack, builtInPackIds.has(pack.id) ? "built_in" : "manual")),
|
|
964
|
+
...configuredMcpCatalogItems(input.settings),
|
|
965
|
+
...platformApiCatalogItems(),
|
|
966
|
+
...bundledSkills
|
|
967
|
+
];
|
|
968
|
+
const items = dedupeCatalogItems([...builtIns, ...persistedItems]).map((item) => applyCapabilityEnablement(item, capabilityInstallationById.get(item.id), activePackIds)).sort(compareCatalogItems);
|
|
969
|
+
return {
|
|
970
|
+
items,
|
|
971
|
+
installations: capabilityInstallations
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
async function createCatalogItem(input) {
|
|
975
|
+
const id = input.payload.id?.trim() || generatedCapabilityId(input.payload);
|
|
976
|
+
if (id.startsWith("pack:")) {
|
|
977
|
+
throw new HTTPException6(422, { message: "packs are managed by OpenGeni and cannot be manually created" });
|
|
978
|
+
}
|
|
979
|
+
const source = input.payload.source === "built_in" || input.payload.source === "configured" ? "manual" : input.payload.source;
|
|
980
|
+
const metadata = {
|
|
981
|
+
...input.payload.metadata,
|
|
982
|
+
...input.payload.kind === "mcp" && input.payload.endpointUrl && !input.payload.metadata.mcpServerId ? { mcpServerId: mcpServerIdForCapability(id, input.payload.metadata) } : {}
|
|
983
|
+
};
|
|
984
|
+
return await upsertCapabilityCatalogItem(input.db, {
|
|
985
|
+
accountId: input.accountId,
|
|
986
|
+
workspaceId: input.workspaceId,
|
|
987
|
+
id,
|
|
988
|
+
kind: input.payload.kind,
|
|
989
|
+
source,
|
|
990
|
+
name: input.payload.name.trim(),
|
|
991
|
+
description: input.payload.description?.trim() || null,
|
|
992
|
+
category: input.payload.category.trim() || "custom",
|
|
993
|
+
tags: uniqueTags(input.payload.tags),
|
|
994
|
+
homepageUrl: input.payload.homepageUrl ?? null,
|
|
995
|
+
endpointUrl: input.payload.endpointUrl ?? null,
|
|
996
|
+
installUrl: input.payload.installUrl ?? null,
|
|
997
|
+
authModel: input.payload.authModel?.trim() || null,
|
|
998
|
+
metadata
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
async function enableCapability(input) {
|
|
1002
|
+
const item = await requireCatalogItem(input.db, input.workspaceId, input.settings, input.capabilityId);
|
|
1003
|
+
if (item.kind === "mcp" && !item.runtime.available) {
|
|
1004
|
+
throw new HTTPException6(422, { message: "MCP capabilities need a remote streamable HTTP endpoint before they can be enabled" });
|
|
1005
|
+
}
|
|
1006
|
+
let installationMetadata = input.payload.metadata;
|
|
1007
|
+
const installationConfig = { ...input.payload.config };
|
|
1008
|
+
delete installationConfig.headers;
|
|
1009
|
+
delete installationConfig.headersEncrypted;
|
|
1010
|
+
delete installationConfig.headerNames;
|
|
1011
|
+
if (item.kind === "mcp") {
|
|
1012
|
+
const headers = await resolveMcpCredentialHeaders(input, item);
|
|
1013
|
+
assertRequiredMcpCredentialHeaders(item, headers);
|
|
1014
|
+
installationMetadata = {
|
|
1015
|
+
...installationMetadata,
|
|
1016
|
+
...await validateMcpCapabilityConnection(item, input.probeMcpServer, headers ?? void 0)
|
|
1017
|
+
};
|
|
1018
|
+
if (headers) {
|
|
1019
|
+
const key = requireCapabilityHeaderEncryption(input.settings);
|
|
1020
|
+
installationConfig.headersEncrypted = Object.fromEntries(
|
|
1021
|
+
Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(key, value)])
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (item.kind === "pack") {
|
|
1026
|
+
const packId = packIdFromCapabilityId(item.id);
|
|
1027
|
+
const pack = await resolveCapabilityPack(input.db, input.workspaceId, packId);
|
|
1028
|
+
if (!pack) {
|
|
1029
|
+
throw new HTTPException6(404, { message: "pack not found" });
|
|
1030
|
+
}
|
|
1031
|
+
await assertPackSandboxImageCompatible(input.db, input.workspaceId, pack);
|
|
1032
|
+
const existing = await getPackInstallation(input.db, input.workspaceId, packId);
|
|
1033
|
+
const storedEnvironmentId = typeof existing?.metadata.environmentId === "string" ? existing.metadata.environmentId : void 0;
|
|
1034
|
+
const requestedEnvironmentId = input.payload.environmentId;
|
|
1035
|
+
const environmentId = requestedEnvironmentId ?? storedEnvironmentId;
|
|
1036
|
+
if (pack.environment?.required && !environmentId) {
|
|
1037
|
+
throw new HTTPException6(422, {
|
|
1038
|
+
message: `pack ${packId} requires an environment attachment; pass environmentId`
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
if (environmentId) {
|
|
1042
|
+
if (requestedEnvironmentId) {
|
|
1043
|
+
const environment = await validateEnvironmentAttachment(
|
|
1044
|
+
{ settings: input.settings, db: input.db },
|
|
1045
|
+
input.grant,
|
|
1046
|
+
input.workspaceId,
|
|
1047
|
+
requestedEnvironmentId
|
|
1048
|
+
);
|
|
1049
|
+
const missing = (pack.environment?.requiredVariables ?? []).filter((name) => !environment.variables.some((variable) => variable.name === name));
|
|
1050
|
+
if (missing.length > 0) {
|
|
1051
|
+
throw new HTTPException6(422, { message: `environment is missing required variable(s): ${missing.join(", ")}` });
|
|
1052
|
+
}
|
|
1053
|
+
} else {
|
|
1054
|
+
const environment = await getWorkspaceEnvironment2(input.db, input.workspaceId, environmentId);
|
|
1055
|
+
if (!environment) {
|
|
1056
|
+
throw new HTTPException6(422, {
|
|
1057
|
+
message: `the stored environment attachment for pack ${packId} no longer exists; re-enable it with environmentId`
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const missing = (pack.environment?.requiredVariables ?? []).filter((name) => !environment.variables.some((variable) => variable.name === name));
|
|
1061
|
+
if (missing.length > 0) {
|
|
1062
|
+
throw new HTTPException6(422, { message: `environment is missing required variable(s): ${missing.join(", ")}` });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
await enablePackInstallation(input.db, {
|
|
1067
|
+
accountId: input.accountId,
|
|
1068
|
+
workspaceId: input.workspaceId,
|
|
1069
|
+
packId,
|
|
1070
|
+
metadata: {
|
|
1071
|
+
...input.payload.metadata,
|
|
1072
|
+
packVersion: pack.version,
|
|
1073
|
+
...environmentId ? { environmentId } : {}
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
return await enableCapabilityInstallation(input.db, {
|
|
1078
|
+
accountId: input.accountId,
|
|
1079
|
+
workspaceId: input.workspaceId,
|
|
1080
|
+
capabilityId: item.id,
|
|
1081
|
+
kind: item.kind,
|
|
1082
|
+
config: installationConfig,
|
|
1083
|
+
metadata: installationMetadata
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
async function resolveMcpCredentialHeaders(input, item) {
|
|
1087
|
+
const provided = normalizedMcpCredentialHeaders(input.payload.headers);
|
|
1088
|
+
if (provided) {
|
|
1089
|
+
requireCapabilityHeaderEncryption(input.settings);
|
|
1090
|
+
return provided;
|
|
1091
|
+
}
|
|
1092
|
+
const storedCiphertext = await getStoredCapabilityHeaderCiphertext(input.db, input.workspaceId, item.id);
|
|
1093
|
+
if (!storedCiphertext) {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
const key = requireCapabilityHeaderEncryption(input.settings);
|
|
1097
|
+
try {
|
|
1098
|
+
return Object.fromEntries(Object.entries(storedCiphertext).map(([name, value]) => [name, decryptEnvironmentValue(key, value)]));
|
|
1099
|
+
} catch {
|
|
1100
|
+
throw new HTTPException6(422, {
|
|
1101
|
+
message: `stored credential headers for "${item.name}" could not be decrypted; supply them again in the enable request "headers" field`
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
function normalizedMcpCredentialHeaders(headers) {
|
|
1106
|
+
const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value]).filter(([name]) => name.length > 0);
|
|
1107
|
+
if (entries.length === 0) {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
if (entries.length > maxMcpCredentialHeaders) {
|
|
1111
|
+
throw new HTTPException6(422, { message: `an MCP capability supports at most ${maxMcpCredentialHeaders} credential headers` });
|
|
1112
|
+
}
|
|
1113
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1114
|
+
for (const [name, value] of entries) {
|
|
1115
|
+
if (!mcpCredentialHeaderName.test(name)) {
|
|
1116
|
+
throw new HTTPException6(422, { message: `invalid credential header name: ${name}` });
|
|
1117
|
+
}
|
|
1118
|
+
const lower = name.toLowerCase();
|
|
1119
|
+
if (seen.has(lower)) {
|
|
1120
|
+
throw new HTTPException6(422, { message: `duplicate credential header name: ${name}` });
|
|
1121
|
+
}
|
|
1122
|
+
seen.add(lower);
|
|
1123
|
+
if (value.length === 0 || value.length > maxMcpCredentialHeaderValueLength) {
|
|
1124
|
+
throw new HTTPException6(422, { message: `credential header ${name} must be 1-${maxMcpCredentialHeaderValueLength} characters` });
|
|
1125
|
+
}
|
|
1126
|
+
if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
|
|
1127
|
+
throw new HTTPException6(422, { message: `credential header ${name} contains forbidden control characters` });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return Object.fromEntries(entries);
|
|
1131
|
+
}
|
|
1132
|
+
function assertRequiredMcpCredentialHeaders(item, headers) {
|
|
1133
|
+
const required = requiredCapabilityHeaders(item.metadata);
|
|
1134
|
+
const names = new Set(Object.keys(headers ?? {}).map((name) => name.toLowerCase()));
|
|
1135
|
+
const missing = required.filter((name) => !names.has(name.toLowerCase()));
|
|
1136
|
+
if (missing.length > 0) {
|
|
1137
|
+
throw new HTTPException6(422, {
|
|
1138
|
+
message: `MCP capability "${item.name}" requires credential header(s) ${missing.join(", ")}; pass them in the enable request "headers" field`
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
if (item.authModel && names.size === 0) {
|
|
1142
|
+
throw new HTTPException6(422, {
|
|
1143
|
+
message: `MCP capability "${item.name}" requires credentials; pass them in the enable request "headers" field`
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function requiredCapabilityHeaders(metadata) {
|
|
1148
|
+
const value = metadata.requiredHeaders;
|
|
1149
|
+
if (!Array.isArray(value)) {
|
|
1150
|
+
return [];
|
|
1151
|
+
}
|
|
1152
|
+
return value.filter((name) => typeof name === "string" && name.trim().length > 0).map((name) => name.trim());
|
|
1153
|
+
}
|
|
1154
|
+
function requireCapabilityHeaderEncryption(settings) {
|
|
1155
|
+
const key = environmentsEncryptionKeyBytes2(settings);
|
|
1156
|
+
if (!key) {
|
|
1157
|
+
throw new HTTPException6(503, { message: "MCP credential headers require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY" });
|
|
1158
|
+
}
|
|
1159
|
+
return key;
|
|
1160
|
+
}
|
|
1161
|
+
async function validateMcpCapabilityConnection(item, probe = probeStreamableHttpMcpServer, headers) {
|
|
1162
|
+
if (item.kind !== "mcp") {
|
|
1163
|
+
return {};
|
|
1164
|
+
}
|
|
1165
|
+
if (!item.endpointUrl || !item.runtime.mcpServerId) {
|
|
1166
|
+
throw new HTTPException6(422, { message: "MCP capabilities need a remote streamable HTTP endpoint before they can be enabled" });
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
const result = await probe({
|
|
1170
|
+
id: item.runtime.mcpServerId,
|
|
1171
|
+
name: item.name,
|
|
1172
|
+
url: item.endpointUrl,
|
|
1173
|
+
timeoutMs: mcpCapabilityProbeTimeoutMs,
|
|
1174
|
+
...headers ? { headers } : {}
|
|
1175
|
+
});
|
|
1176
|
+
return {
|
|
1177
|
+
mcpConnectivity: {
|
|
1178
|
+
status: "ok",
|
|
1179
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1180
|
+
toolCount: result.toolCount
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
throw new HTTPException6(422, {
|
|
1185
|
+
message: `MCP capability "${item.name}" could not be enabled because OpenGeni could not initialize ${item.endpointUrl}: ${mcpProbeErrorMessage(error)}`
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
async function probeStreamableHttpMcpServer(input) {
|
|
1190
|
+
const controller = new AbortController();
|
|
1191
|
+
const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1192
|
+
const client = new Client({ name: "opengeni-capability-probe", version: "0.1.0" }, { capabilities: {} });
|
|
1193
|
+
try {
|
|
1194
|
+
const transport = new StreamableHTTPClientTransport(new URL(input.url), {
|
|
1195
|
+
requestInit: {
|
|
1196
|
+
signal: controller.signal,
|
|
1197
|
+
...input.headers ? { headers: input.headers } : {}
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
await client.connect(transport, { timeout: input.timeoutMs, maxTotalTimeout: input.timeoutMs });
|
|
1201
|
+
const tools = await client.listTools(void 0, { timeout: input.timeoutMs, maxTotalTimeout: input.timeoutMs });
|
|
1202
|
+
return { toolCount: tools.tools.length };
|
|
1203
|
+
} finally {
|
|
1204
|
+
clearTimeout(timeout);
|
|
1205
|
+
await client.close().catch(() => void 0);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function mcpProbeErrorMessage(error) {
|
|
1209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1210
|
+
return message.replace(/\s+/g, " ").trim().slice(0, 500) || "unknown error";
|
|
1211
|
+
}
|
|
1212
|
+
async function disableCapability(input) {
|
|
1213
|
+
const item = await requireCatalogItem(input.db, input.workspaceId, input.settings, input.capabilityId);
|
|
1214
|
+
if ((item.source === "built_in" || item.source === "configured") && item.kind !== "pack") {
|
|
1215
|
+
throw new HTTPException6(409, { message: "built-in and configured capabilities are always available; remove them from configuration to disable them" });
|
|
1216
|
+
}
|
|
1217
|
+
if (item.kind === "pack") {
|
|
1218
|
+
await updatePackInstallationStatus(input.db, input.workspaceId, packIdFromCapabilityId(item.id), "disabled").catch(() => void 0);
|
|
1219
|
+
if (!await getCapabilityInstallation(input.db, input.workspaceId, item.id)) {
|
|
1220
|
+
await enableCapabilityInstallation(input.db, {
|
|
1221
|
+
accountId: input.accountId,
|
|
1222
|
+
workspaceId: input.workspaceId,
|
|
1223
|
+
capabilityId: item.id,
|
|
1224
|
+
kind: "pack",
|
|
1225
|
+
metadata: {},
|
|
1226
|
+
config: {}
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
} else if (!await getCapabilityInstallation(input.db, input.workspaceId, item.id)) {
|
|
1230
|
+
throw new HTTPException6(409, { message: "capability is not currently enabled" });
|
|
1231
|
+
}
|
|
1232
|
+
return await disableCapabilityInstallation(input.db, input.workspaceId, item.id);
|
|
1233
|
+
}
|
|
1234
|
+
async function settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings) {
|
|
1235
|
+
const enabled = await listEnabledMcpCapabilityServers(db, workspaceId);
|
|
1236
|
+
return settingsWithMcpCapabilityServers(settings, enabled);
|
|
1237
|
+
}
|
|
1238
|
+
function settingsWithMcpCapabilityServers(settings, enabled) {
|
|
1239
|
+
if (enabled.length === 0) {
|
|
1240
|
+
return settings;
|
|
1241
|
+
}
|
|
1242
|
+
const encryptionKey = environmentsEncryptionKeyBytes2(settings);
|
|
1243
|
+
const existingIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
1244
|
+
const dynamicServers = enabled.filter((server) => !existingIds.has(server.id)).flatMap((server) => {
|
|
1245
|
+
const headers = decryptedCapabilityHeaders(server, encryptionKey);
|
|
1246
|
+
if (headers === "unavailable") {
|
|
1247
|
+
return [];
|
|
1248
|
+
}
|
|
1249
|
+
return [{
|
|
1250
|
+
id: server.id,
|
|
1251
|
+
name: server.name,
|
|
1252
|
+
url: server.url,
|
|
1253
|
+
...server.allowedTools ? { allowedTools: server.allowedTools } : {},
|
|
1254
|
+
...server.timeoutMs ? { timeoutMs: server.timeoutMs } : {},
|
|
1255
|
+
cacheToolsList: server.cacheToolsList ?? false,
|
|
1256
|
+
...headers ? { headers } : {}
|
|
1257
|
+
}];
|
|
1258
|
+
});
|
|
1259
|
+
return dynamicServers.length ? { ...settings, mcpServers: [...settings.mcpServers, ...dynamicServers] } : settings;
|
|
1260
|
+
}
|
|
1261
|
+
async function discoverMcpRegistryCapabilities(input) {
|
|
1262
|
+
const query = (input.query ?? "").trim().toLowerCase();
|
|
1263
|
+
const limit = Math.min(100, Math.max(1, Math.floor(input.limit ?? 50)));
|
|
1264
|
+
const items = [];
|
|
1265
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1266
|
+
const fetchOptions = {};
|
|
1267
|
+
if (input.fetchImpl) {
|
|
1268
|
+
fetchOptions.fetchImpl = input.fetchImpl;
|
|
1269
|
+
}
|
|
1270
|
+
if (input.timeoutMs !== void 0) {
|
|
1271
|
+
fetchOptions.timeoutMs = input.timeoutMs;
|
|
1272
|
+
}
|
|
1273
|
+
let cursor;
|
|
1274
|
+
let pages = 0;
|
|
1275
|
+
while (items.length < limit && pages < mcpRegistryMaxPages) {
|
|
1276
|
+
pages += 1;
|
|
1277
|
+
const url = new URL("/v0.1/servers", officialMcpRegistryUrl);
|
|
1278
|
+
url.searchParams.set("limit", String(limit));
|
|
1279
|
+
url.searchParams.set("version", "latest");
|
|
1280
|
+
if (query) {
|
|
1281
|
+
url.searchParams.set("search", query);
|
|
1282
|
+
}
|
|
1283
|
+
if (cursor) {
|
|
1284
|
+
url.searchParams.set("cursor", cursor);
|
|
1285
|
+
}
|
|
1286
|
+
const page = await fetchMcpRegistryPage(url, fetchOptions);
|
|
1287
|
+
for (const entry of page.servers ?? []) {
|
|
1288
|
+
const item = mcpRegistryEntryToCatalogItem(entry);
|
|
1289
|
+
if (!item || seen.has(item.id)) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (query && !catalogSearchText(item).includes(query)) {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
seen.add(item.id);
|
|
1296
|
+
items.push(item);
|
|
1297
|
+
if (items.length >= limit) {
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
cursor = typeof page.metadata?.nextCursor === "string" ? page.metadata.nextCursor : void 0;
|
|
1302
|
+
if (!cursor) {
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return items;
|
|
1307
|
+
}
|
|
1308
|
+
async function fetchMcpRegistryPage(url, options = {}) {
|
|
1309
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1310
|
+
const controller = new AbortController();
|
|
1311
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? mcpRegistryFetchTimeoutMs);
|
|
1312
|
+
try {
|
|
1313
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
1314
|
+
if (!response.ok) {
|
|
1315
|
+
throw new HTTPException6(502, { message: `MCP registry returned ${response.status}` });
|
|
1316
|
+
}
|
|
1317
|
+
return await response.json();
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
if (error instanceof HTTPException6) {
|
|
1320
|
+
throw error;
|
|
1321
|
+
}
|
|
1322
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1323
|
+
throw new HTTPException6(504, { message: "MCP registry request timed out" });
|
|
1324
|
+
}
|
|
1325
|
+
throw new HTTPException6(502, {
|
|
1326
|
+
message: `MCP registry request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1327
|
+
});
|
|
1328
|
+
} finally {
|
|
1329
|
+
clearTimeout(timeout);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async function requireCatalogItem(db, workspaceId, settings, capabilityId) {
|
|
1333
|
+
const catalog = await buildCapabilityCatalog({ db, workspaceId, settings });
|
|
1334
|
+
const item = catalog.items.find((candidate) => candidate.id === capabilityId) ?? await getCapabilityCatalogItem(db, workspaceId, capabilityId);
|
|
1335
|
+
if (!item) {
|
|
1336
|
+
throw new HTTPException6(404, { message: "capability not found" });
|
|
1337
|
+
}
|
|
1338
|
+
return item;
|
|
1339
|
+
}
|
|
1340
|
+
function packCatalogItem(pack, source) {
|
|
1341
|
+
return CapabilityCatalogItem.parse({
|
|
1342
|
+
id: `pack:${pack.id}`,
|
|
1343
|
+
kind: "pack",
|
|
1344
|
+
source,
|
|
1345
|
+
name: pack.name,
|
|
1346
|
+
description: pack.description,
|
|
1347
|
+
category: pack.category,
|
|
1348
|
+
tags: [pack.role, pack.category, "pack"],
|
|
1349
|
+
tools: pack.tools,
|
|
1350
|
+
runtime: {
|
|
1351
|
+
available: true,
|
|
1352
|
+
notes: "Enables role-scoped tools, connectors, knowledge, and scheduled-task templates."
|
|
1353
|
+
},
|
|
1354
|
+
metadata: {
|
|
1355
|
+
packId: pack.id,
|
|
1356
|
+
version: pack.version,
|
|
1357
|
+
connectors: pack.connectors,
|
|
1358
|
+
knowledge: pack.knowledge,
|
|
1359
|
+
scheduledTaskTemplates: pack.scheduledTaskTemplates,
|
|
1360
|
+
// Runtime composition surface only: skill names, never file content.
|
|
1361
|
+
...pack.sandboxImage ? { sandboxImage: pack.sandboxImage } : {},
|
|
1362
|
+
...pack.skills.length > 0 ? { skills: pack.skills.map((skill) => skill.name) } : {},
|
|
1363
|
+
...pack.metadata
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
function configuredMcpCatalogItems(settings) {
|
|
1368
|
+
return settings.mcpServers.map((server) => CapabilityCatalogItem.parse({
|
|
1369
|
+
id: `mcp:${server.id}`,
|
|
1370
|
+
kind: "mcp",
|
|
1371
|
+
source: firstPartyMcpServerIds.has(server.id) ? "built_in" : "configured",
|
|
1372
|
+
name: server.name ?? server.id,
|
|
1373
|
+
description: firstPartyMcpDescription(server.id),
|
|
1374
|
+
category: firstPartyMcpServerIds.has(server.id) ? "platform" : "configured",
|
|
1375
|
+
tags: ["mcp", ...server.allowedTools?.length ? ["limited-tools"] : []],
|
|
1376
|
+
endpointUrl: server.url,
|
|
1377
|
+
tools: [{ kind: "mcp", id: server.id }],
|
|
1378
|
+
runtime: {
|
|
1379
|
+
available: true,
|
|
1380
|
+
mcpServerId: server.id,
|
|
1381
|
+
transport: "streamable-http",
|
|
1382
|
+
notes: firstPartyMcpServerIds.has(server.id) ? "Available from OpenGeni runtime configuration." : "Configured through OPENGENI_MCP_SERVERS."
|
|
1383
|
+
},
|
|
1384
|
+
metadata: {
|
|
1385
|
+
mcpServerId: server.id,
|
|
1386
|
+
allowedTools: server.allowedTools ?? [],
|
|
1387
|
+
cacheToolsList: server.cacheToolsList
|
|
1388
|
+
}
|
|
1389
|
+
}));
|
|
1390
|
+
}
|
|
1391
|
+
function platformApiCatalogItems() {
|
|
1392
|
+
return [
|
|
1393
|
+
{
|
|
1394
|
+
id: "api:github-app",
|
|
1395
|
+
name: "GitHub App",
|
|
1396
|
+
description: "Repository discovery, scoped clone tokens, pushes, and pull requests.",
|
|
1397
|
+
category: "source-control",
|
|
1398
|
+
tags: ["api", "github", "repositories"],
|
|
1399
|
+
endpointPath: "/v1/workspaces/{workspaceId}/github/app"
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
id: "api:documents",
|
|
1403
|
+
name: "Document Knowledge Base",
|
|
1404
|
+
description: "Upload, index, search, and attach knowledge bases to agents.",
|
|
1405
|
+
category: "knowledge",
|
|
1406
|
+
tags: ["api", "documents", "knowledge"],
|
|
1407
|
+
endpointPath: "/v1/workspaces/{workspaceId}/document-bases"
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
id: "api:social",
|
|
1411
|
+
name: "Social Accounts",
|
|
1412
|
+
description: "Connect social accounts and ingest posts for marketing agents.",
|
|
1413
|
+
category: "marketing",
|
|
1414
|
+
tags: ["api", "social", "marketing"],
|
|
1415
|
+
endpointPath: "/v1/workspaces/{workspaceId}/social/connections"
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
id: "api:scheduled-tasks",
|
|
1419
|
+
name: "Scheduled Tasks",
|
|
1420
|
+
description: "Run agents once, on intervals, or on calendar schedules.",
|
|
1421
|
+
category: "automation",
|
|
1422
|
+
tags: ["api", "schedules", "agents"],
|
|
1423
|
+
endpointPath: "/v1/workspaces/{workspaceId}/scheduled-tasks"
|
|
1424
|
+
}
|
|
1425
|
+
].map((item) => CapabilityCatalogItem.parse({
|
|
1426
|
+
id: item.id,
|
|
1427
|
+
name: item.name,
|
|
1428
|
+
description: item.description,
|
|
1429
|
+
category: item.category,
|
|
1430
|
+
tags: item.tags,
|
|
1431
|
+
kind: "api",
|
|
1432
|
+
source: "built_in",
|
|
1433
|
+
runtime: {
|
|
1434
|
+
available: true,
|
|
1435
|
+
notes: "Available through the OpenGeni API."
|
|
1436
|
+
},
|
|
1437
|
+
metadata: {
|
|
1438
|
+
endpointPath: item.endpointPath
|
|
1439
|
+
}
|
|
1440
|
+
}));
|
|
1441
|
+
}
|
|
1442
|
+
async function discoverBundledSkills() {
|
|
1443
|
+
const skillsDir = new URL("../../../../packages/runtime/src/bundled_hashicorp_terraform_skills/", import.meta.url);
|
|
1444
|
+
try {
|
|
1445
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
1446
|
+
const skills = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
1447
|
+
const skill = await readSkillMetadata(new URL(`${entry.name}/SKILL.md`, skillsDir), entry.name);
|
|
1448
|
+
return CapabilityCatalogItem.parse({
|
|
1449
|
+
id: `skill:${entry.name}`,
|
|
1450
|
+
kind: "skill",
|
|
1451
|
+
source: "built_in",
|
|
1452
|
+
name: skill.name,
|
|
1453
|
+
description: skill.description,
|
|
1454
|
+
category: skill.category,
|
|
1455
|
+
tags: ["skill", skill.category],
|
|
1456
|
+
runtime: {
|
|
1457
|
+
available: true,
|
|
1458
|
+
notes: "Bundled into the sandbox skill library."
|
|
1459
|
+
},
|
|
1460
|
+
metadata: {
|
|
1461
|
+
path: `packages/runtime/src/bundled_hashicorp_terraform_skills/${entry.name}/SKILL.md`
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
}));
|
|
1465
|
+
return skills;
|
|
1466
|
+
} catch {
|
|
1467
|
+
return [];
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
async function readSkillMetadata(url, fallbackName) {
|
|
1471
|
+
const content = await readFile(url, "utf8");
|
|
1472
|
+
const frontMatter = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1473
|
+
const frontMatterBody = frontMatter?.[1] ?? "";
|
|
1474
|
+
const name = frontMatterBody.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
|
|
1475
|
+
const blockDescription = frontMatterBody.match(/^description:\s*>-\s*\n([\s\S]*?)(?:\n[a-zA-Z_-]+:|\n?$)/m)?.[1]?.split("\n").map((line) => line.trim()).filter(Boolean).join(" ");
|
|
1476
|
+
const inlineDescription = frontMatterBody.match(/^description:\s*(?!>-\s*$)(.+)$/m)?.[1]?.trim();
|
|
1477
|
+
const description = blockDescription || inlineDescription || content.match(/^#\s+(.+)$/m)?.[1]?.trim() || null;
|
|
1478
|
+
const lower = `${fallbackName} ${name} ${description ?? ""}`.toLowerCase();
|
|
1479
|
+
const category = lower.includes("social") || lower.includes("marketing") ? "marketing" : lower.includes("checkov") || lower.includes("terraform") || lower.includes("azure") ? "infrastructure" : "general";
|
|
1480
|
+
return { name, description, category };
|
|
1481
|
+
}
|
|
1482
|
+
function applyCapabilityEnablement(item, installation, activePackIds) {
|
|
1483
|
+
if (item.kind === "pack") {
|
|
1484
|
+
const enabled2 = activePackIds.has(packIdFromCapabilityId(item.id)) || installation?.status === "active";
|
|
1485
|
+
return {
|
|
1486
|
+
...item,
|
|
1487
|
+
enabled: enabled2,
|
|
1488
|
+
enabledReason: enabled2 ? "enabled" : null
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
if (item.source === "built_in" || item.source === "configured") {
|
|
1492
|
+
return {
|
|
1493
|
+
...item,
|
|
1494
|
+
enabled: true,
|
|
1495
|
+
enabledReason: item.source === "configured" ? "configured" : "built in"
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
const activeInstallation = installation?.status === "active";
|
|
1499
|
+
const enabled = !!activeInstallation && capabilityInstallationRuntimeReady(item, installation);
|
|
1500
|
+
return {
|
|
1501
|
+
...item,
|
|
1502
|
+
enabled,
|
|
1503
|
+
enabledReason: enabled ? "enabled" : null
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
function dedupeCatalogItems(items) {
|
|
1507
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1508
|
+
for (const item of items) {
|
|
1509
|
+
byId.set(item.id, item);
|
|
1510
|
+
}
|
|
1511
|
+
return [...byId.values()];
|
|
1512
|
+
}
|
|
1513
|
+
function compareCatalogItems(a, b) {
|
|
1514
|
+
return `${a.kind}:${a.category}:${a.name}`.localeCompare(`${b.kind}:${b.category}:${b.name}`);
|
|
1515
|
+
}
|
|
1516
|
+
function firstPartyMcpDescription(id) {
|
|
1517
|
+
if (id === "opengeni") {
|
|
1518
|
+
return "First-party OpenGeni MCP tools for files, documents, schedules, and social analysis.";
|
|
1519
|
+
}
|
|
1520
|
+
if (id === "docs") {
|
|
1521
|
+
return "Document-base search tools for indexed knowledge.";
|
|
1522
|
+
}
|
|
1523
|
+
if (id === "files") {
|
|
1524
|
+
return "File download URL tools for sandbox-mounted file resources.";
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function generatedCapabilityId(payload) {
|
|
1529
|
+
const source = [payload.kind, payload.name, payload.endpointUrl ?? payload.installUrl ?? payload.homepageUrl ?? ""].join(":");
|
|
1530
|
+
return `${payload.kind}:${slugify(payload.name)}-${shortHash(source)}`;
|
|
1531
|
+
}
|
|
1532
|
+
function publicRegistryCapabilityId(name, version, endpointUrl) {
|
|
1533
|
+
return `mcp-registry:${slugify(name)}-${shortHash(`${name}:${version}:${endpointUrl}`)}`;
|
|
1534
|
+
}
|
|
1535
|
+
function packIdFromCapabilityId(capabilityId) {
|
|
1536
|
+
return capabilityId.replace(/^pack:/, "");
|
|
1537
|
+
}
|
|
1538
|
+
function uniqueTags(tags) {
|
|
1539
|
+
return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
1540
|
+
}
|
|
1541
|
+
function slugify(value) {
|
|
1542
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "capability";
|
|
1543
|
+
}
|
|
1544
|
+
function shortHash(value) {
|
|
1545
|
+
let hash = 2166136261;
|
|
1546
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1547
|
+
hash ^= value.charCodeAt(index);
|
|
1548
|
+
hash = Math.imul(hash, 16777619);
|
|
1549
|
+
}
|
|
1550
|
+
return (hash >>> 0).toString(36).padStart(7, "0").slice(0, 7);
|
|
1551
|
+
}
|
|
1552
|
+
function mcpRegistryEntryToCatalogItem(entry) {
|
|
1553
|
+
const server = entry.server;
|
|
1554
|
+
if (!server?.name) {
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
const official = entry._meta?.["io.modelcontextprotocol.registry/official"];
|
|
1558
|
+
if (official?.status && official.status !== "active") {
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
if (official?.isLatest === false) {
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
const remote = server.remotes?.find((candidate) => candidate.type === "streamable-http" && candidate.url);
|
|
1565
|
+
const endpointUrl = validUrl(remote?.url);
|
|
1566
|
+
if (!remote || !endpointUrl) {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
const version = server.version ?? "latest";
|
|
1570
|
+
const id = publicRegistryCapabilityId(server.name, version, endpointUrl);
|
|
1571
|
+
const homepageUrl = validUrl(server.websiteUrl) ?? validUrl(server.repository?.url);
|
|
1572
|
+
const requiredHeaders = requiredRemoteHeaders(remote);
|
|
1573
|
+
const mcpServerId = mcpServerIdForCapability(id, {});
|
|
1574
|
+
return CapabilityCatalogItem.parse({
|
|
1575
|
+
id,
|
|
1576
|
+
kind: "mcp",
|
|
1577
|
+
source: "public_registry",
|
|
1578
|
+
name: server.title || server.name,
|
|
1579
|
+
description: server.description ?? null,
|
|
1580
|
+
category: "public-mcp",
|
|
1581
|
+
tags: ["mcp", "public", "registry", ...requiredHeaders.length ? ["requires-credentials"] : []],
|
|
1582
|
+
homepageUrl,
|
|
1583
|
+
endpointUrl,
|
|
1584
|
+
installUrl: homepageUrl,
|
|
1585
|
+
authModel: requiredHeaders.length ? "credential_ref" : null,
|
|
1586
|
+
tools: [{ kind: "mcp", id: mcpServerId }],
|
|
1587
|
+
runtime: {
|
|
1588
|
+
available: true,
|
|
1589
|
+
mcpServerId,
|
|
1590
|
+
transport: "streamable-http",
|
|
1591
|
+
notes: requiredHeaders.length === 0 ? "Remote MCP server from the official MCP Registry." : `This MCP requires credential header(s) ${requiredHeaders.join(", ")} supplied in the enable request.`
|
|
1592
|
+
},
|
|
1593
|
+
metadata: {
|
|
1594
|
+
registry: "official_mcp_registry",
|
|
1595
|
+
registryName: server.name,
|
|
1596
|
+
version,
|
|
1597
|
+
updatedAt: official?.updatedAt,
|
|
1598
|
+
packages: server.packages ?? [],
|
|
1599
|
+
requiredHeaders
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
function requiredRemoteHeaders(remote) {
|
|
1604
|
+
return (remote.headers ?? []).filter((header) => header.name && header.isRequired !== false).map((header) => header.name.trim()).filter(Boolean);
|
|
1605
|
+
}
|
|
1606
|
+
function validUrl(value) {
|
|
1607
|
+
if (!value) {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
try {
|
|
1611
|
+
return new URL(value).toString();
|
|
1612
|
+
} catch {
|
|
1613
|
+
return null;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function catalogSearchText(item) {
|
|
1617
|
+
return [
|
|
1618
|
+
item.name,
|
|
1619
|
+
item.description,
|
|
1620
|
+
item.category,
|
|
1621
|
+
...item.tags,
|
|
1622
|
+
item.endpointUrl,
|
|
1623
|
+
item.homepageUrl,
|
|
1624
|
+
item.installUrl,
|
|
1625
|
+
JSON.stringify(item.metadata)
|
|
1626
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
1627
|
+
}
|
|
1628
|
+
function capabilityInstallationRuntimeReady(item, installation) {
|
|
1629
|
+
if (!installation || item.kind !== "mcp") {
|
|
1630
|
+
return !!installation;
|
|
1631
|
+
}
|
|
1632
|
+
if (!item.runtime.available) {
|
|
1633
|
+
return false;
|
|
1634
|
+
}
|
|
1635
|
+
if (!storedCredentialHeadersSatisfy(item, installation)) {
|
|
1636
|
+
return false;
|
|
1637
|
+
}
|
|
1638
|
+
const connectivity = installation.metadata.mcpConnectivity;
|
|
1639
|
+
return !!connectivity && typeof connectivity === "object" && "status" in connectivity && connectivity.status === "ok";
|
|
1640
|
+
}
|
|
1641
|
+
function storedCredentialHeadersSatisfy(item, installation) {
|
|
1642
|
+
const storedNames = new Set(
|
|
1643
|
+
(Array.isArray(installation.config.headerNames) ? installation.config.headerNames : []).filter((name) => typeof name === "string").map((name) => name.toLowerCase())
|
|
1644
|
+
);
|
|
1645
|
+
const required = requiredCapabilityHeaders(item.metadata);
|
|
1646
|
+
if (required.some((name) => !storedNames.has(name.toLowerCase()))) {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
return !item.authModel || storedNames.size > 0;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/domain/resources.ts
|
|
1653
|
+
import {
|
|
1654
|
+
mergeResourceRefs as mergeContractResourceRefs,
|
|
1655
|
+
mergeToolRefs,
|
|
1656
|
+
resourceIdentityKey,
|
|
1657
|
+
ResourceRefConflictError,
|
|
1658
|
+
stableJson
|
|
1659
|
+
} from "@opengeni/contracts";
|
|
1660
|
+
import {
|
|
1661
|
+
listGitHubInstallationIdsForWorkspace,
|
|
1662
|
+
requireFile
|
|
1663
|
+
} from "@opengeni/db";
|
|
1664
|
+
import { HTTPException as HTTPException7 } from "hono/http-exception";
|
|
1665
|
+
function validateToolRefs(tools, settings) {
|
|
1666
|
+
const mcpServerIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
1667
|
+
const selected = /* @__PURE__ */ new Set();
|
|
1668
|
+
const out = [];
|
|
1669
|
+
for (const tool of tools) {
|
|
1670
|
+
if (tool.kind !== "mcp") {
|
|
1671
|
+
throw new HTTPException7(422, { message: `unsupported tool kind: ${tool.kind}` });
|
|
1672
|
+
}
|
|
1673
|
+
if (!mcpServerIds.has(tool.id)) {
|
|
1674
|
+
throw new HTTPException7(422, { message: `unknown MCP server id: ${tool.id}` });
|
|
1675
|
+
}
|
|
1676
|
+
if (selected.has(tool.id)) {
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
selected.add(tool.id);
|
|
1680
|
+
out.push({ kind: "mcp", id: tool.id });
|
|
1681
|
+
}
|
|
1682
|
+
return out;
|
|
1683
|
+
}
|
|
1684
|
+
function enabledCapabilityMcpToolRefs(settings, runtimeSettings) {
|
|
1685
|
+
const configuredIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
1686
|
+
return runtimeSettings.mcpServers.filter((server) => !configuredIds.has(server.id)).map((server) => ({ kind: "mcp", id: server.id, optional: true }));
|
|
1687
|
+
}
|
|
1688
|
+
function withDefaultEnabledCapabilityMcpTools(tools, settings, runtimeSettings) {
|
|
1689
|
+
return mergeToolRefs(tools, enabledCapabilityMcpToolRefs(settings, runtimeSettings));
|
|
1690
|
+
}
|
|
1691
|
+
function normalizeResources(resources) {
|
|
1692
|
+
const mountPaths = /* @__PURE__ */ new Map();
|
|
1693
|
+
const identities = /* @__PURE__ */ new Map();
|
|
1694
|
+
const seenResources = /* @__PURE__ */ new Set();
|
|
1695
|
+
const out = [];
|
|
1696
|
+
for (const resource of resources) {
|
|
1697
|
+
let normalized;
|
|
1698
|
+
if (resource.kind === "file") {
|
|
1699
|
+
const mountPath = normalizeMountPath(resource.mountPath ?? `files/${resource.fileId}`);
|
|
1700
|
+
normalized = {
|
|
1701
|
+
kind: "file",
|
|
1702
|
+
fileId: resource.fileId,
|
|
1703
|
+
mountPath
|
|
1704
|
+
};
|
|
1705
|
+
} else {
|
|
1706
|
+
const url = parseResourceUrl(resource.uri);
|
|
1707
|
+
if (url.protocol !== "https:" || !url.hostname) {
|
|
1708
|
+
throw new HTTPException7(422, { message: "repository resources must use HTTPS Git URLs" });
|
|
1709
|
+
}
|
|
1710
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
1711
|
+
const parts = path.split("/").filter(Boolean);
|
|
1712
|
+
if (parts.length < 2) {
|
|
1713
|
+
throw new HTTPException7(422, { message: "repository URL must include owner and repo" });
|
|
1714
|
+
}
|
|
1715
|
+
const repo = parts.join("/");
|
|
1716
|
+
const mountPath = normalizeMountPath(resource.mountPath ?? `repos/${repo}`);
|
|
1717
|
+
normalized = {
|
|
1718
|
+
kind: "repository",
|
|
1719
|
+
uri: `https://${url.hostname.toLowerCase()}/${repo}.git`,
|
|
1720
|
+
ref: resource.ref.trim(),
|
|
1721
|
+
mountPath,
|
|
1722
|
+
...resource.subpath ? { subpath: normalizeMountPath(resource.subpath) } : {},
|
|
1723
|
+
...resource.githubInstallationId ? { githubInstallationId: resource.githubInstallationId } : {},
|
|
1724
|
+
...resource.githubRepositoryId ? { githubRepositoryId: resource.githubRepositoryId } : {}
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const key = stableJson(normalized);
|
|
1728
|
+
const mounted = normalized.mountPath ? mountPaths.get(normalized.mountPath) : void 0;
|
|
1729
|
+
if (mounted && mounted !== key) {
|
|
1730
|
+
throw new HTTPException7(422, { message: `duplicate resource mount path: ${normalized.mountPath}` });
|
|
1731
|
+
}
|
|
1732
|
+
if (normalized.mountPath) {
|
|
1733
|
+
mountPaths.set(normalized.mountPath, key);
|
|
1734
|
+
}
|
|
1735
|
+
const identity = resourceIdentityKey(normalized);
|
|
1736
|
+
const seenIdentity = identities.get(identity);
|
|
1737
|
+
if (seenIdentity && seenIdentity !== key) {
|
|
1738
|
+
throw new HTTPException7(422, { message: `duplicate resource with different settings: ${identity}` });
|
|
1739
|
+
}
|
|
1740
|
+
identities.set(identity, key);
|
|
1741
|
+
if (!seenResources.has(key)) {
|
|
1742
|
+
seenResources.add(key);
|
|
1743
|
+
out.push(normalized);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return out;
|
|
1747
|
+
}
|
|
1748
|
+
function mergeResourceRefs(existing, additions) {
|
|
1749
|
+
try {
|
|
1750
|
+
return mergeContractResourceRefs(existing, additions, { rejectConflicts: true });
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
if (error instanceof ResourceRefConflictError) {
|
|
1753
|
+
throw new HTTPException7(422, { message: error.message });
|
|
1754
|
+
}
|
|
1755
|
+
throw error;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function validateGitHubRepositorySelectionShape(resources) {
|
|
1759
|
+
const selected = resources.flatMap((resource) => {
|
|
1760
|
+
if (resource.kind !== "repository") {
|
|
1761
|
+
return [];
|
|
1762
|
+
}
|
|
1763
|
+
const installationRaw = resource.githubInstallationId;
|
|
1764
|
+
const repositoryRaw = resource.githubRepositoryId;
|
|
1765
|
+
if (installationRaw === null && repositoryRaw === null) {
|
|
1766
|
+
return [];
|
|
1767
|
+
}
|
|
1768
|
+
if (installationRaw === void 0 && repositoryRaw === void 0) {
|
|
1769
|
+
return [];
|
|
1770
|
+
}
|
|
1771
|
+
const installationId2 = positiveInteger(installationRaw);
|
|
1772
|
+
const repositoryId = positiveInteger(repositoryRaw);
|
|
1773
|
+
if (!installationId2 || !repositoryId) {
|
|
1774
|
+
throw new HTTPException7(422, {
|
|
1775
|
+
message: "GitHub App repository resources require positive github_installation_id and github_repository_id"
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
return [{ installationId: installationId2, repositoryId }];
|
|
1779
|
+
});
|
|
1780
|
+
if (selected.length === 0) {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
const installationId = selected[0].installationId;
|
|
1784
|
+
if (selected.some((item) => item.installationId !== installationId)) {
|
|
1785
|
+
throw new HTTPException7(422, {
|
|
1786
|
+
message: "GitHub App repository resources must belong to one installation"
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
return installationId;
|
|
1790
|
+
}
|
|
1791
|
+
async function validateGitHubRepositorySelection(db, workspaceId, resources) {
|
|
1792
|
+
const installationId = validateGitHubRepositorySelectionShape(resources);
|
|
1793
|
+
if (installationId === null) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const linkedInstallationIds = new Set(await listGitHubInstallationIdsForWorkspace(db, workspaceId));
|
|
1797
|
+
if (!linkedInstallationIds.has(installationId)) {
|
|
1798
|
+
throw new HTTPException7(422, {
|
|
1799
|
+
message: "GitHub App repository resources must belong to a GitHub App installation linked to this workspace"
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
async function validateFileResources(db, workspaceId, resources) {
|
|
1804
|
+
const fileIds = /* @__PURE__ */ new Set();
|
|
1805
|
+
for (const resource of resources) {
|
|
1806
|
+
if (resource.kind !== "file") {
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (fileIds.has(resource.fileId)) {
|
|
1810
|
+
throw new HTTPException7(422, { message: `duplicate file resource: ${resource.fileId}` });
|
|
1811
|
+
}
|
|
1812
|
+
fileIds.add(resource.fileId);
|
|
1813
|
+
const file = await requireFile(db, workspaceId, resource.fileId).catch(() => null);
|
|
1814
|
+
if (!file) {
|
|
1815
|
+
throw new HTTPException7(422, { message: `unknown file resource: ${resource.fileId}` });
|
|
1816
|
+
}
|
|
1817
|
+
if (file.status !== "ready") {
|
|
1818
|
+
throw new HTTPException7(422, { message: `file resource ${resource.fileId} is ${file.status}` });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
function normalizeMountPath(path) {
|
|
1823
|
+
const normalized = path.trim().replace(/^\/+|\/+$/g, "");
|
|
1824
|
+
if (!normalized || normalized.includes("..")) {
|
|
1825
|
+
throw new HTTPException7(422, { message: `invalid resource mount path: ${path}` });
|
|
1826
|
+
}
|
|
1827
|
+
return normalized;
|
|
1828
|
+
}
|
|
1829
|
+
function parseResourceUrl(uri) {
|
|
1830
|
+
try {
|
|
1831
|
+
return new URL(uri);
|
|
1832
|
+
} catch {
|
|
1833
|
+
throw new HTTPException7(422, { message: "repository resources must use valid URLs" });
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function positiveInteger(value) {
|
|
1837
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
|
1838
|
+
return value;
|
|
1839
|
+
}
|
|
1840
|
+
if (typeof value === "string" && /^\d+$/.test(value) && Number(value) > 0) {
|
|
1841
|
+
return Number(value);
|
|
1842
|
+
}
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// src/domain/scheduled-tasks.ts
|
|
1847
|
+
import {
|
|
1848
|
+
createScheduledTask,
|
|
1849
|
+
deleteScheduledTask,
|
|
1850
|
+
getScheduledTask,
|
|
1851
|
+
updateScheduledTask
|
|
1852
|
+
} from "@opengeni/db";
|
|
1853
|
+
import { HTTPException as HTTPException9 } from "hono/http-exception";
|
|
1854
|
+
|
|
1855
|
+
// src/domain/sessions.ts
|
|
1856
|
+
import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
|
|
1857
|
+
import { configuredAllowedModels } from "@opengeni/config";
|
|
1858
|
+
import {
|
|
1859
|
+
CreateSessionRequest,
|
|
1860
|
+
reasoningEffortForMetadata
|
|
1861
|
+
} from "@opengeni/contracts";
|
|
1862
|
+
import {
|
|
1863
|
+
appendSessionEventsWithLockedSessionUpdate,
|
|
1864
|
+
createSession,
|
|
1865
|
+
createSessionGoal,
|
|
1866
|
+
createSessionWithIdempotencyKey,
|
|
1867
|
+
enqueueSessionTurn,
|
|
1868
|
+
getAnySessionInGroup,
|
|
1869
|
+
getEnrollment as getEnrollment2,
|
|
1870
|
+
listDistinctEnvironmentIdsInGroup,
|
|
1871
|
+
getSandbox as getSandbox3,
|
|
1872
|
+
getSession,
|
|
1873
|
+
getSessionByCreateIdempotencyKey,
|
|
1874
|
+
getSessionTurn,
|
|
1875
|
+
requireSession as requireSession2,
|
|
1876
|
+
setTemporalWorkflowId,
|
|
1877
|
+
updateSessionTitle as updateSessionTitleRow
|
|
1878
|
+
} from "@opengeni/db";
|
|
1879
|
+
import { appendAndPublishEvents } from "@opengeni/events";
|
|
1880
|
+
import { HTTPException as HTTPException8 } from "hono/http-exception";
|
|
1881
|
+
async function createAndStartSession(input) {
|
|
1882
|
+
const sessionMetadata = {
|
|
1883
|
+
...input.metadata,
|
|
1884
|
+
model: input.model,
|
|
1885
|
+
reasoningEffort: input.reasoningEffort
|
|
1886
|
+
};
|
|
1887
|
+
if (input.createIdempotencyKey) {
|
|
1888
|
+
const existing = await getSessionByCreateIdempotencyKey(input.db, input.workspaceId, input.createIdempotencyKey);
|
|
1889
|
+
if (existing) {
|
|
1890
|
+
return existing;
|
|
1891
|
+
}
|
|
1892
|
+
const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
|
|
1893
|
+
accountId: input.accountId,
|
|
1894
|
+
workspaceId: input.workspaceId,
|
|
1895
|
+
initialMessage: input.initialMessage,
|
|
1896
|
+
resources: input.resources,
|
|
1897
|
+
tools: input.tools,
|
|
1898
|
+
metadata: sessionMetadata,
|
|
1899
|
+
model: input.model,
|
|
1900
|
+
sandboxBackend: input.sandboxBackend,
|
|
1901
|
+
environmentId: input.environment?.id ?? null,
|
|
1902
|
+
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
1903
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
1904
|
+
createIdempotencyKey: input.createIdempotencyKey,
|
|
1905
|
+
sandboxGroupId: input.sandboxGroupId ?? null,
|
|
1906
|
+
...input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}
|
|
1907
|
+
});
|
|
1908
|
+
if (!created) {
|
|
1909
|
+
return keyed;
|
|
1910
|
+
}
|
|
1911
|
+
return await finishStartSession(input, keyed);
|
|
1912
|
+
}
|
|
1913
|
+
const session = await createSession(input.db, {
|
|
1914
|
+
accountId: input.accountId,
|
|
1915
|
+
workspaceId: input.workspaceId,
|
|
1916
|
+
initialMessage: input.initialMessage,
|
|
1917
|
+
resources: input.resources,
|
|
1918
|
+
tools: input.tools,
|
|
1919
|
+
metadata: sessionMetadata,
|
|
1920
|
+
model: input.model,
|
|
1921
|
+
sandboxBackend: input.sandboxBackend,
|
|
1922
|
+
environmentId: input.environment?.id ?? null,
|
|
1923
|
+
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
1924
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
1925
|
+
sandboxGroupId: input.sandboxGroupId ?? null,
|
|
1926
|
+
...input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}
|
|
1927
|
+
});
|
|
1928
|
+
return await finishStartSession(input, session);
|
|
1929
|
+
}
|
|
1930
|
+
async function finishStartSession(input, session) {
|
|
1931
|
+
const goal = input.goal ? await createSessionGoal(input.db, {
|
|
1932
|
+
accountId: session.accountId,
|
|
1933
|
+
workspaceId: session.workspaceId,
|
|
1934
|
+
sessionId: session.id,
|
|
1935
|
+
text: input.goal.text,
|
|
1936
|
+
successCriteria: input.goal.successCriteria ?? null,
|
|
1937
|
+
maxAutoContinuations: input.goal.maxAutoContinuations ?? null,
|
|
1938
|
+
createdBy: "api"
|
|
1939
|
+
}) : null;
|
|
1940
|
+
const initialPayload = {
|
|
1941
|
+
text: input.initialMessage,
|
|
1942
|
+
...input.resources.length ? { resources: input.resources } : {},
|
|
1943
|
+
...input.tools.length ? { tools: input.tools } : {}
|
|
1944
|
+
};
|
|
1945
|
+
const events = await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [
|
|
1946
|
+
{
|
|
1947
|
+
type: "session.created",
|
|
1948
|
+
payload: {
|
|
1949
|
+
status: "queued",
|
|
1950
|
+
...input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
...goal ? [{
|
|
1954
|
+
type: "goal.set",
|
|
1955
|
+
payload: {
|
|
1956
|
+
goalId: goal.id,
|
|
1957
|
+
text: goal.text,
|
|
1958
|
+
...goal.successCriteria ? { successCriteria: goal.successCriteria } : {},
|
|
1959
|
+
version: goal.version,
|
|
1960
|
+
actor: "api",
|
|
1961
|
+
replaced: false
|
|
1962
|
+
}
|
|
1963
|
+
}] : [],
|
|
1964
|
+
{
|
|
1965
|
+
type: "user.message",
|
|
1966
|
+
payload: initialPayload,
|
|
1967
|
+
...input.clientEventId ? { clientEventId: input.clientEventId } : {}
|
|
1968
|
+
},
|
|
1969
|
+
{ type: "session.status.changed", payload: { status: "queued" } }
|
|
1970
|
+
]);
|
|
1971
|
+
const userEvent = events.find((event) => event.type === "user.message");
|
|
1972
|
+
if (!userEvent) {
|
|
1973
|
+
throw new HTTPException8(500, { message: "failed to append initial user event" });
|
|
1974
|
+
}
|
|
1975
|
+
if (input.seedTargetSandbox) {
|
|
1976
|
+
if (session.sandboxBackend === "none") {
|
|
1977
|
+
throw new HTTPException8(422, {
|
|
1978
|
+
message: "cannot target a machine for a session with no sandbox (backend: none)"
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
const ctx = {
|
|
1982
|
+
accountId: session.accountId,
|
|
1983
|
+
workspaceId: session.workspaceId,
|
|
1984
|
+
sessionId: session.id,
|
|
1985
|
+
sessionBackend: session.sandboxBackend,
|
|
1986
|
+
sessionGroupId: session.sandboxGroupId
|
|
1987
|
+
};
|
|
1988
|
+
const seeded = await swapActiveSandbox(
|
|
1989
|
+
{ db: input.db, settings: input.seedTargetSandbox.settings, bus: input.bus },
|
|
1990
|
+
ctx,
|
|
1991
|
+
input.seedTargetSandbox.sandboxId,
|
|
1992
|
+
// The working dir is committed in the SAME epoch-fenced CAS that seeds the
|
|
1993
|
+
// pointer, so the first turn routes to the machine AND lands in working_dir.
|
|
1994
|
+
input.seedTargetSandbox.workingDir ?? null
|
|
1995
|
+
);
|
|
1996
|
+
if (!seeded.swapped) {
|
|
1997
|
+
throw new HTTPException8(422, {
|
|
1998
|
+
message: `cannot target sandbox ${input.seedTargetSandbox.sandboxId}: ${seeded.reason ?? "target is not attachable"}`
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const workflowId = workflowIdForSession(session.id);
|
|
2003
|
+
await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
|
|
2004
|
+
const turn = await enqueueSessionTurn(input.db, {
|
|
2005
|
+
accountId: session.accountId,
|
|
2006
|
+
workspaceId: session.workspaceId,
|
|
2007
|
+
sessionId: session.id,
|
|
2008
|
+
triggerEventId: userEvent.id,
|
|
2009
|
+
temporalWorkflowId: workflowId,
|
|
2010
|
+
source: "user",
|
|
2011
|
+
prompt: input.initialMessage,
|
|
2012
|
+
resources: input.resources,
|
|
2013
|
+
tools: input.tools,
|
|
2014
|
+
model: input.model,
|
|
2015
|
+
reasoningEffort: input.reasoningEffort,
|
|
2016
|
+
sandboxBackend: input.sandboxBackend,
|
|
2017
|
+
metadata: {}
|
|
2018
|
+
});
|
|
2019
|
+
await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [{
|
|
2020
|
+
type: "turn.queued",
|
|
2021
|
+
turnId: turn.id,
|
|
2022
|
+
payload: { turnId: turn.id, triggerEventId: userEvent.id, source: turn.source }
|
|
2023
|
+
}]);
|
|
2024
|
+
await input.workflowClient.wakeSessionWorkflow({ accountId: session.accountId, workspaceId: session.workspaceId, sessionId: session.id, workflowId });
|
|
2025
|
+
return await requireSession2(input.db, session.workspaceId, session.id);
|
|
2026
|
+
}
|
|
2027
|
+
function workflowIdForSession(sessionId) {
|
|
2028
|
+
return `session-${sessionId}`;
|
|
2029
|
+
}
|
|
2030
|
+
function assertConfiguredModel(settings, model) {
|
|
2031
|
+
if (model === null || model === void 0) {
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (configuredAllowedModels(settings).includes(model)) {
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
throw new HTTPException8(422, { message: `model is not available: ${model}` });
|
|
2041
|
+
}
|
|
2042
|
+
async function requireQueuedTurnForApi(db, workspaceId, sessionId, turnId) {
|
|
2043
|
+
const turn = await getSessionTurn(db, workspaceId, turnId);
|
|
2044
|
+
if (!turn || turn.sessionId !== sessionId) {
|
|
2045
|
+
throw new HTTPException8(404, { message: "session turn not found" });
|
|
2046
|
+
}
|
|
2047
|
+
if (turn.status !== "queued") {
|
|
2048
|
+
throw new HTTPException8(409, { message: `turn is ${turn.status}; only queued turns can be changed` });
|
|
2049
|
+
}
|
|
2050
|
+
return turn;
|
|
2051
|
+
}
|
|
2052
|
+
function reasoningEffortForSession(metadata, fallback) {
|
|
2053
|
+
return reasoningEffortForMetadata(metadata, fallback);
|
|
2054
|
+
}
|
|
2055
|
+
async function postUserMessageTurn(input) {
|
|
2056
|
+
const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
|
|
2057
|
+
const requestedModel = input.model ?? null;
|
|
2058
|
+
const requestedReasoningEffort = input.reasoningEffort ?? null;
|
|
2059
|
+
assertConfiguredModel(settings, requestedModel);
|
|
2060
|
+
const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, (lockedSession) => {
|
|
2061
|
+
if (lockedSession.status === "cancelled") {
|
|
2062
|
+
throw new HTTPException8(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
|
|
2063
|
+
}
|
|
2064
|
+
const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
|
|
2065
|
+
const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
|
|
2066
|
+
const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
|
|
2067
|
+
return {
|
|
2068
|
+
events: [
|
|
2069
|
+
{
|
|
2070
|
+
type: "user.message",
|
|
2071
|
+
payload: {
|
|
2072
|
+
text: input.text,
|
|
2073
|
+
...input.resources.length ? { resources: input.resources } : {},
|
|
2074
|
+
...input.tools.length ? { tools: input.tools } : {},
|
|
2075
|
+
...requestedModel ? { model: requestedModel } : {},
|
|
2076
|
+
...requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}
|
|
2077
|
+
},
|
|
2078
|
+
...input.clientEventId ? { clientEventId: input.clientEventId } : {}
|
|
2079
|
+
},
|
|
2080
|
+
...shouldQueueSession ? [{ type: "session.status.changed", payload: { status: "queued" } }] : []
|
|
2081
|
+
],
|
|
2082
|
+
update: {
|
|
2083
|
+
resources: nextResources,
|
|
2084
|
+
tools: nextTools,
|
|
2085
|
+
...shouldQueueSession ? { status: "queued", activeTurnId: null } : {}
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
}).then(async (events) => {
|
|
2089
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
2090
|
+
return events;
|
|
2091
|
+
});
|
|
2092
|
+
const accepted = appended[0];
|
|
2093
|
+
if (!accepted) {
|
|
2094
|
+
throw new HTTPException8(500, { message: "failed to append client event" });
|
|
2095
|
+
}
|
|
2096
|
+
const workflowId = workflowIdForSession(sessionId);
|
|
2097
|
+
const session = await requireSession2(db, workspaceId, sessionId);
|
|
2098
|
+
const turn = await enqueueSessionTurn(db, {
|
|
2099
|
+
accountId,
|
|
2100
|
+
workspaceId,
|
|
2101
|
+
sessionId,
|
|
2102
|
+
triggerEventId: accepted.id,
|
|
2103
|
+
temporalWorkflowId: workflowId,
|
|
2104
|
+
source: "user",
|
|
2105
|
+
prompt: input.text,
|
|
2106
|
+
resources: input.resources,
|
|
2107
|
+
tools: input.tools,
|
|
2108
|
+
model: requestedModel ?? session.model,
|
|
2109
|
+
reasoningEffort: requestedReasoningEffort ?? reasoningEffortForSession(session.metadata, settings.openaiReasoningEffort),
|
|
2110
|
+
sandboxBackend: session.sandboxBackend,
|
|
2111
|
+
metadata: {}
|
|
2112
|
+
});
|
|
2113
|
+
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
|
|
2114
|
+
type: "turn.queued",
|
|
2115
|
+
turnId: turn.id,
|
|
2116
|
+
payload: { turnId: turn.id, triggerEventId: accepted.id, source: turn.source }
|
|
2117
|
+
}]);
|
|
2118
|
+
await workflowClient.wakeSessionWorkflow({ accountId, workspaceId, sessionId, workflowId });
|
|
2119
|
+
return { accepted, turn };
|
|
2120
|
+
}
|
|
2121
|
+
async function createSessionForRequest(deps, grant, workspaceId, rawPayload) {
|
|
2122
|
+
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
2123
|
+
const payload = CreateSessionRequest.parse(rawPayload);
|
|
2124
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
|
|
2125
|
+
const resources = normalizeResources(payload.resources);
|
|
2126
|
+
const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
|
|
2127
|
+
const defaultedTools = hasOwnProperty(rawPayload, "tools") ? requestedTools : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, runtimeSettings);
|
|
2128
|
+
const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
|
|
2129
|
+
await validateGitHubRepositorySelection(db, workspaceId, resources);
|
|
2130
|
+
if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
2131
|
+
throw new HTTPException8(503, { message: "object storage is not configured" });
|
|
2132
|
+
}
|
|
2133
|
+
await validateFileResources(db, workspaceId, resources);
|
|
2134
|
+
const environment = payload.environmentId ? await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, payload.environmentId) : null;
|
|
2135
|
+
assertConfiguredModel(settings, payload.model);
|
|
2136
|
+
const model = payload.model ?? settings.openaiModel;
|
|
2137
|
+
const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
|
|
2138
|
+
let firstPartyMcpPermissions = payload.firstPartyMcpPermissions ?? null;
|
|
2139
|
+
if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
|
|
2140
|
+
throw new HTTPException8(422, { message: "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set" });
|
|
2141
|
+
}
|
|
2142
|
+
for (const permission of firstPartyMcpPermissions ?? []) {
|
|
2143
|
+
if (!hasPermission(grant.permissions, permission)) {
|
|
2144
|
+
throw new HTTPException8(403, { message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}` });
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
if (payload.goal && firstPartyMcpPermissions && !firstPartyMcpPermissions.includes("goals:manage")) {
|
|
2148
|
+
firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
|
|
2149
|
+
}
|
|
2150
|
+
const parentSessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] : null;
|
|
2151
|
+
const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
|
|
2152
|
+
let sandboxGroupId = null;
|
|
2153
|
+
let inheritedBackend;
|
|
2154
|
+
const requestedEnvironmentId = payload.environmentId ?? null;
|
|
2155
|
+
const environmentMatchesGroup = (memberEnvironmentId) => memberEnvironmentId === requestedEnvironmentId;
|
|
2156
|
+
if (sandboxChoice === "shared") {
|
|
2157
|
+
if (!parentSessionId) {
|
|
2158
|
+
throw new HTTPException8(422, { message: "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create." });
|
|
2159
|
+
}
|
|
2160
|
+
const parent = await getSession(db, workspaceId, parentSessionId);
|
|
2161
|
+
if (!parent) {
|
|
2162
|
+
throw new HTTPException8(404, { message: `parent session not found in workspace: ${parentSessionId}` });
|
|
2163
|
+
}
|
|
2164
|
+
if (parent.sandboxBackend !== "none" && !environmentMatchesGroup(parent.environmentId ?? null)) {
|
|
2165
|
+
if (payload.sandbox === "shared") {
|
|
2166
|
+
throw new HTTPException8(422, { message: "sandbox:'shared' requires the same environment as the creator's box (the box environment is fixed at creation); omit sandbox or pass 'new' when attaching a different environment." });
|
|
2167
|
+
}
|
|
2168
|
+
} else {
|
|
2169
|
+
sandboxGroupId = parent.sandboxGroupId;
|
|
2170
|
+
inheritedBackend = parent.sandboxBackend;
|
|
2171
|
+
}
|
|
2172
|
+
} else if (typeof sandboxChoice === "object") {
|
|
2173
|
+
const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
|
|
2174
|
+
if (!member) {
|
|
2175
|
+
throw new HTTPException8(404, { message: `sandbox group not found in workspace: ${sandboxChoice.groupId}` });
|
|
2176
|
+
}
|
|
2177
|
+
if (member.sandboxBackend !== "none") {
|
|
2178
|
+
const memberEnvironmentIds = await listDistinctEnvironmentIdsInGroup(db, workspaceId, sandboxChoice.groupId);
|
|
2179
|
+
if (!memberEnvironmentIds.every((memberEnvironmentId) => environmentMatchesGroup(memberEnvironmentId))) {
|
|
2180
|
+
throw new HTTPException8(422, { message: `sandbox group ${sandboxChoice.groupId} runs a different environment (the box environment is fixed at creation); create with the group's environment or omit sandbox for an own box.` });
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
sandboxGroupId = sandboxChoice.groupId;
|
|
2184
|
+
inheritedBackend = member.sandboxBackend;
|
|
2185
|
+
}
|
|
2186
|
+
if (payload.workingDir !== void 0 && !payload.targetSandboxId) {
|
|
2187
|
+
throw new HTTPException8(422, { message: "workingDir requires targetSandboxId (it is the targeted machine's working directory)" });
|
|
2188
|
+
}
|
|
2189
|
+
let machineHomeBackend;
|
|
2190
|
+
let machineHomeOs;
|
|
2191
|
+
if (payload.targetSandboxId && inheritedBackend === void 0 && settings.sandboxOwnershipEnabled && settings.sandboxSelfhostedEnabled) {
|
|
2192
|
+
const targetSandbox = await getSandbox3(db, workspaceId, payload.targetSandboxId);
|
|
2193
|
+
if (targetSandbox?.kind === "selfhosted") {
|
|
2194
|
+
machineHomeBackend = "selfhosted";
|
|
2195
|
+
if (targetSandbox.enrollmentId) {
|
|
2196
|
+
const enrollment = await getEnrollment2(db, workspaceId, targetSandbox.enrollmentId);
|
|
2197
|
+
if (enrollment && (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")) {
|
|
2198
|
+
machineHomeOs = enrollment.os;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model });
|
|
2204
|
+
const session = await createAndStartSession({
|
|
2205
|
+
db,
|
|
2206
|
+
bus,
|
|
2207
|
+
workflowClient,
|
|
2208
|
+
accountId: grant.accountId,
|
|
2209
|
+
workspaceId,
|
|
2210
|
+
initialMessage: payload.initialMessage,
|
|
2211
|
+
resources,
|
|
2212
|
+
tools,
|
|
2213
|
+
...payload.clientEventId ? { clientEventId: payload.clientEventId } : {},
|
|
2214
|
+
model,
|
|
2215
|
+
reasoningEffort,
|
|
2216
|
+
// A shared spawn inherits the box's backend; a caller-supplied
|
|
2217
|
+
// sandboxBackend on a shared spawn is ignored (it is the same box). A
|
|
2218
|
+
// machine-targeted top-level create labels the home "selfhosted"
|
|
2219
|
+
// (machineHomeBackend), overriding the caller/deployment default so the row
|
|
2220
|
+
// matches where the session actually runs.
|
|
2221
|
+
sandboxBackend: inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
|
|
2222
|
+
// Mirror the backend relabel on the OS axis: only a machine-targeted
|
|
2223
|
+
// top-level create carries a derived OS; everything else is omitted and the
|
|
2224
|
+
// "linux" default holds (shared spawns keep the parent-box behavior).
|
|
2225
|
+
...machineHomeOs ? { sandboxOs: machineHomeOs } : {},
|
|
2226
|
+
sandboxGroupId,
|
|
2227
|
+
metadata: payload.metadata,
|
|
2228
|
+
environment: environment ? { id: environment.id, name: environment.name } : null,
|
|
2229
|
+
goal: payload.goal ?? null,
|
|
2230
|
+
firstPartyMcpPermissions,
|
|
2231
|
+
parentSessionId,
|
|
2232
|
+
createIdempotencyKey: payload.idempotencyKey ?? null,
|
|
2233
|
+
// Create-time machine targeting (A-2a): when a target sandbox is named, the
|
|
2234
|
+
// active-sandbox pointer is seeded race-free inside createAndStartSession
|
|
2235
|
+
// (after the row exists, before the first turn dispatches). Validation
|
|
2236
|
+
// (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
|
|
2237
|
+
seedTargetSandbox: payload.targetSandboxId ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null } : null
|
|
2238
|
+
});
|
|
2239
|
+
await recordWorkspaceUsage(deps, {
|
|
2240
|
+
accountId: grant.accountId,
|
|
2241
|
+
workspaceId,
|
|
2242
|
+
subjectId: grant.subjectId,
|
|
2243
|
+
eventType: "agent_run.created",
|
|
2244
|
+
quantity: 1,
|
|
2245
|
+
unit: "run",
|
|
2246
|
+
sourceResourceType: "session",
|
|
2247
|
+
sourceResourceId: session.id,
|
|
2248
|
+
idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`
|
|
2249
|
+
});
|
|
2250
|
+
return session;
|
|
2251
|
+
}
|
|
2252
|
+
async function acceptSessionUserMessage(deps, grant, workspaceId, sessionId, input) {
|
|
2253
|
+
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
2254
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
|
|
2255
|
+
const requestedResources = normalizeResources(input.resources ?? []);
|
|
2256
|
+
const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
|
|
2257
|
+
const requestedTools = input.toolsProvided ? validatedTools : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, runtimeSettings);
|
|
2258
|
+
const existingSession = await requireSession2(db, workspaceId, sessionId);
|
|
2259
|
+
await requireLimit(deps, {
|
|
2260
|
+
accountId: grant.accountId,
|
|
2261
|
+
workspaceId,
|
|
2262
|
+
action: "agent_run:create",
|
|
2263
|
+
quantity: 1,
|
|
2264
|
+
model: input.model ?? existingSession.model
|
|
2265
|
+
});
|
|
2266
|
+
if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
2267
|
+
throw new HTTPException8(503, { message: "object storage is not configured" });
|
|
2268
|
+
}
|
|
2269
|
+
await validateFileResources(db, workspaceId, requestedResources);
|
|
2270
|
+
await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
|
|
2271
|
+
const { accepted, turn } = await postUserMessageTurn({
|
|
2272
|
+
db,
|
|
2273
|
+
bus,
|
|
2274
|
+
workflowClient,
|
|
2275
|
+
settings,
|
|
2276
|
+
accountId: grant.accountId,
|
|
2277
|
+
workspaceId,
|
|
2278
|
+
sessionId,
|
|
2279
|
+
text: input.text,
|
|
2280
|
+
resources: requestedResources,
|
|
2281
|
+
tools: requestedTools,
|
|
2282
|
+
model: input.model ?? null,
|
|
2283
|
+
reasoningEffort: input.reasoningEffort ?? null,
|
|
2284
|
+
...input.clientEventId ? { clientEventId: input.clientEventId } : {}
|
|
2285
|
+
});
|
|
2286
|
+
await recordWorkspaceUsage(deps, {
|
|
2287
|
+
accountId: grant.accountId,
|
|
2288
|
+
workspaceId,
|
|
2289
|
+
subjectId: grant.subjectId,
|
|
2290
|
+
eventType: "agent_run.created",
|
|
2291
|
+
quantity: 1,
|
|
2292
|
+
unit: "run",
|
|
2293
|
+
sourceResourceType: "session_turn",
|
|
2294
|
+
sourceResourceId: turn.id,
|
|
2295
|
+
idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`
|
|
2296
|
+
});
|
|
2297
|
+
return { accepted, turn };
|
|
2298
|
+
}
|
|
2299
|
+
async function updateSessionTitle(deps, workspaceId, sessionId, title, source) {
|
|
2300
|
+
const { db, bus } = deps;
|
|
2301
|
+
const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
|
|
2302
|
+
if (result.updated) {
|
|
2303
|
+
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
|
|
2304
|
+
type: "session.title_set",
|
|
2305
|
+
payload: {
|
|
2306
|
+
title: result.title ?? title,
|
|
2307
|
+
source
|
|
2308
|
+
}
|
|
2309
|
+
}]);
|
|
2310
|
+
}
|
|
2311
|
+
return result;
|
|
2312
|
+
}
|
|
2313
|
+
function withFirstPartyTools(tools, runtimeSettings) {
|
|
2314
|
+
if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
|
|
2315
|
+
return tools;
|
|
2316
|
+
}
|
|
2317
|
+
return mergeToolRefs(tools, [{ kind: "mcp", id: "opengeni" }]);
|
|
2318
|
+
}
|
|
2319
|
+
function hasOwnProperty(value, key) {
|
|
2320
|
+
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// src/domain/scheduled-tasks.ts
|
|
2324
|
+
function scheduledTaskToolsProvided(rawPayload) {
|
|
2325
|
+
if (!rawPayload || typeof rawPayload !== "object") {
|
|
2326
|
+
return false;
|
|
2327
|
+
}
|
|
2328
|
+
const agentConfig = rawPayload.agentConfig;
|
|
2329
|
+
return Boolean(
|
|
2330
|
+
agentConfig && typeof agentConfig === "object" && Object.prototype.hasOwnProperty.call(agentConfig, "tools")
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
async function createValidatedScheduledTask(input) {
|
|
2334
|
+
const agentConfig = await validateScheduledTaskAgentConfig({ ...input, workspaceId: input.grant.workspaceId });
|
|
2335
|
+
const id = crypto.randomUUID();
|
|
2336
|
+
validateScheduledTaskSchedule(input.payload.schedule);
|
|
2337
|
+
if (input.payload.environmentId) {
|
|
2338
|
+
await validateEnvironmentAttachment(
|
|
2339
|
+
{ settings: input.settings, db: input.db },
|
|
2340
|
+
input.grant,
|
|
2341
|
+
input.grant.workspaceId,
|
|
2342
|
+
input.payload.environmentId,
|
|
2343
|
+
{ preauthorized: input.environmentPreauthorized ?? false }
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
return await createScheduledTask(input.db, {
|
|
2347
|
+
id,
|
|
2348
|
+
accountId: input.grant.accountId,
|
|
2349
|
+
workspaceId: input.grant.workspaceId,
|
|
2350
|
+
name: trimmedScheduledTaskName(input.payload.name),
|
|
2351
|
+
status: input.payload.status,
|
|
2352
|
+
schedule: input.payload.schedule,
|
|
2353
|
+
temporalScheduleId: scheduledTaskTemporalScheduleId(id),
|
|
2354
|
+
runMode: input.payload.runMode,
|
|
2355
|
+
overlapPolicy: input.payload.overlapPolicy,
|
|
2356
|
+
agentConfig,
|
|
2357
|
+
environmentId: input.payload.environmentId ?? null,
|
|
2358
|
+
metadata: input.payload.metadata
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
async function validatedScheduledTaskUpdate(input) {
|
|
2362
|
+
const update = {};
|
|
2363
|
+
if (input.payload.name !== void 0) {
|
|
2364
|
+
update.name = trimmedScheduledTaskName(input.payload.name);
|
|
2365
|
+
}
|
|
2366
|
+
if (input.payload.status !== void 0) {
|
|
2367
|
+
update.status = input.payload.status;
|
|
2368
|
+
}
|
|
2369
|
+
if (input.payload.schedule !== void 0) {
|
|
2370
|
+
validateScheduledTaskSchedule(input.payload.schedule);
|
|
2371
|
+
update.schedule = input.payload.schedule;
|
|
2372
|
+
}
|
|
2373
|
+
if (input.payload.runMode !== void 0) {
|
|
2374
|
+
update.runMode = input.payload.runMode;
|
|
2375
|
+
}
|
|
2376
|
+
if (input.payload.overlapPolicy !== void 0) {
|
|
2377
|
+
update.overlapPolicy = input.payload.overlapPolicy;
|
|
2378
|
+
}
|
|
2379
|
+
if (input.payload.metadata !== void 0) {
|
|
2380
|
+
update.metadata = input.payload.metadata;
|
|
2381
|
+
}
|
|
2382
|
+
if (input.payload.environmentId !== void 0) {
|
|
2383
|
+
const nextEnvironmentId = input.payload.environmentId;
|
|
2384
|
+
if ((input.existing.environmentId ?? null) !== (nextEnvironmentId ?? null) && input.existing.runMode === "reusable_session" && input.existing.reusableSessionId) {
|
|
2385
|
+
throw new HTTPException9(409, { message: "cannot change environment of a task with a live reusable session; recreate the task" });
|
|
2386
|
+
}
|
|
2387
|
+
if (nextEnvironmentId === null) {
|
|
2388
|
+
if (input.existing.environmentId !== null) {
|
|
2389
|
+
requirePermission(input.grant, "environments:use");
|
|
2390
|
+
}
|
|
2391
|
+
update.environmentId = null;
|
|
2392
|
+
} else {
|
|
2393
|
+
await validateEnvironmentAttachment(
|
|
2394
|
+
{ settings: input.settings, db: input.db },
|
|
2395
|
+
input.grant,
|
|
2396
|
+
input.existing.workspaceId,
|
|
2397
|
+
nextEnvironmentId
|
|
2398
|
+
);
|
|
2399
|
+
update.environmentId = nextEnvironmentId;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
if (input.payload.agentConfig !== void 0) {
|
|
2403
|
+
const willHaveEnvironment = input.payload.environmentId !== void 0 ? input.payload.environmentId !== null : Boolean(input.existing.environmentId);
|
|
2404
|
+
if (willHaveEnvironment) {
|
|
2405
|
+
requirePermission(input.grant, "environments:use");
|
|
2406
|
+
}
|
|
2407
|
+
update.agentConfig = await validateScheduledTaskAgentConfig({
|
|
2408
|
+
settings: input.settings,
|
|
2409
|
+
db: input.db,
|
|
2410
|
+
objectStorage: input.objectStorage,
|
|
2411
|
+
workspaceId: input.existing.workspaceId,
|
|
2412
|
+
payload: { agentConfig: input.payload.agentConfig },
|
|
2413
|
+
...input.toolsProvided !== void 0 ? { toolsProvided: input.toolsProvided } : {}
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
return update;
|
|
2417
|
+
}
|
|
2418
|
+
async function requireScheduledTaskForApi(db, workspaceId, taskId) {
|
|
2419
|
+
const task = await getScheduledTask(db, workspaceId, taskId);
|
|
2420
|
+
if (!task) {
|
|
2421
|
+
throw new HTTPException9(404, { message: "scheduled task not found" });
|
|
2422
|
+
}
|
|
2423
|
+
return task;
|
|
2424
|
+
}
|
|
2425
|
+
async function restoreScheduledTask(db, task) {
|
|
2426
|
+
return await updateScheduledTask(db, task.workspaceId, task.id, {
|
|
2427
|
+
name: task.name,
|
|
2428
|
+
status: task.status,
|
|
2429
|
+
schedule: task.schedule,
|
|
2430
|
+
runMode: task.runMode,
|
|
2431
|
+
overlapPolicy: task.overlapPolicy,
|
|
2432
|
+
agentConfig: task.agentConfig,
|
|
2433
|
+
reusableSessionId: task.reusableSessionId,
|
|
2434
|
+
environmentId: task.environmentId,
|
|
2435
|
+
metadata: task.metadata
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
async function syncCreatedScheduledTask(input) {
|
|
2439
|
+
try {
|
|
2440
|
+
await input.workflowClient.syncScheduledTask({ task: input.task });
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id).catch(() => void 0);
|
|
2443
|
+
throw error;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
async function syncUpdatedScheduledTask(input) {
|
|
2447
|
+
try {
|
|
2448
|
+
await input.workflowClient.syncScheduledTask({ task: input.task });
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
await restoreScheduledTask(input.db, input.previous).catch(() => void 0);
|
|
2451
|
+
throw error;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
function scheduledTaskTemporalScheduleId(taskId) {
|
|
2455
|
+
return `scheduled-task-${taskId}`;
|
|
2456
|
+
}
|
|
2457
|
+
function scheduledTaskTriggerToken(clientTriggerId) {
|
|
2458
|
+
const trimmed = (clientTriggerId ?? "").trim();
|
|
2459
|
+
if (!trimmed) {
|
|
2460
|
+
return crypto.randomUUID();
|
|
2461
|
+
}
|
|
2462
|
+
const safe = trimmed.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 128);
|
|
2463
|
+
return safe.length > 0 ? safe : crypto.randomUUID();
|
|
2464
|
+
}
|
|
2465
|
+
function manualScheduledTaskTriggerWorkflowId(taskId, triggerToken) {
|
|
2466
|
+
return `scheduled-task-${taskId}-manual-${triggerToken}`;
|
|
2467
|
+
}
|
|
2468
|
+
function manualScheduledTaskTriggerUsageKey(workspaceId, taskId, triggerToken) {
|
|
2469
|
+
return `agent_run.created:scheduled-trigger:${workspaceId}:${taskId}:${triggerToken}`;
|
|
2470
|
+
}
|
|
2471
|
+
async function validateScheduledTaskAgentConfig(input) {
|
|
2472
|
+
assertConfiguredModel(input.settings, input.payload.agentConfig.model);
|
|
2473
|
+
const resources = normalizeResources(input.payload.agentConfig.resources ?? []);
|
|
2474
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(input.db, input.workspaceId, input.settings);
|
|
2475
|
+
const requestedTools = validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
|
|
2476
|
+
const tools = input.toolsProvided ?? true ? requestedTools : withDefaultEnabledCapabilityMcpTools(requestedTools, input.settings, runtimeSettings);
|
|
2477
|
+
const prompt = input.payload.agentConfig.prompt.trim();
|
|
2478
|
+
if (!prompt) {
|
|
2479
|
+
throw new HTTPException9(422, { message: "scheduled task prompt is required" });
|
|
2480
|
+
}
|
|
2481
|
+
await validateGitHubRepositorySelection(input.db, input.workspaceId, resources);
|
|
2482
|
+
if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
|
|
2483
|
+
throw new HTTPException9(503, { message: "object storage is not configured" });
|
|
2484
|
+
}
|
|
2485
|
+
await validateFileResources(input.db, input.workspaceId, resources);
|
|
2486
|
+
return {
|
|
2487
|
+
...input.payload.agentConfig,
|
|
2488
|
+
prompt,
|
|
2489
|
+
resources,
|
|
2490
|
+
tools
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function validateScheduledTaskSchedule(schedule) {
|
|
2494
|
+
if (schedule.type !== "interval" || !schedule.startAt || !schedule.endAt) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (new Date(schedule.startAt).getTime() >= new Date(schedule.endAt).getTime()) {
|
|
2498
|
+
throw new HTTPException9(422, { message: "interval schedule endAt must be after startAt" });
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
function trimmedScheduledTaskName(name) {
|
|
2502
|
+
const trimmed = name.trim();
|
|
2503
|
+
if (!trimmed) {
|
|
2504
|
+
throw new HTTPException9(422, { message: "scheduled task name is required" });
|
|
2505
|
+
}
|
|
2506
|
+
return trimmed;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
// src/domain/workspace-members.ts
|
|
2510
|
+
import { HTTPException as HTTPException10 } from "hono/http-exception";
|
|
2511
|
+
var MEMBER_ADMIN_PERMISSIONS = ["workspace:admin", "members:manage"];
|
|
2512
|
+
function memberCanAdminister(member) {
|
|
2513
|
+
return member.permissions.some((permission) => MEMBER_ADMIN_PERMISSIONS.includes(permission));
|
|
2514
|
+
}
|
|
2515
|
+
function isUserMember(member) {
|
|
2516
|
+
return member.subjectId.startsWith("user:");
|
|
2517
|
+
}
|
|
2518
|
+
function resolveMemberSubjectId(userId) {
|
|
2519
|
+
if (!userId) {
|
|
2520
|
+
throw new HTTPException10(404, { message: "user is not registered" });
|
|
2521
|
+
}
|
|
2522
|
+
return `user:${userId}`;
|
|
2523
|
+
}
|
|
2524
|
+
function assertWorkspaceMemberRemovable(input) {
|
|
2525
|
+
const { members, subjectId, callerSubjectId } = input;
|
|
2526
|
+
if (subjectId === callerSubjectId) {
|
|
2527
|
+
throw new HTTPException10(409, { message: "you cannot remove your own membership" });
|
|
2528
|
+
}
|
|
2529
|
+
const target = members.find((member) => member.subjectId === subjectId);
|
|
2530
|
+
if (!target) {
|
|
2531
|
+
throw new HTTPException10(404, { message: "member not found" });
|
|
2532
|
+
}
|
|
2533
|
+
if (memberCanAdminister(target)) {
|
|
2534
|
+
const remainingAdmins = members.filter((member) => member.subjectId !== subjectId && memberCanAdminister(member));
|
|
2535
|
+
if (remainingAdmins.length === 0) {
|
|
2536
|
+
throw new HTTPException10(409, { message: "cannot remove the last member who can manage this workspace" });
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
function assertWorkspaceDeletable(input) {
|
|
2541
|
+
if (input.workspaceCountForAccount <= 1) {
|
|
2542
|
+
throw new HTTPException10(409, { message: "cannot delete the account's only workspace" });
|
|
2543
|
+
}
|
|
2544
|
+
if (input.activeSessionCount > 0) {
|
|
2545
|
+
throw new HTTPException10(409, {
|
|
2546
|
+
message: "stop the workspace's running sessions before deleting it"
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
export {
|
|
2551
|
+
MARKETING_SOCIAL_PACK_ID,
|
|
2552
|
+
MAX_ENVIRONMENTS_PER_WORKSPACE,
|
|
2553
|
+
MAX_VARIABLES_PER_ENVIRONMENT,
|
|
2554
|
+
acceptSessionUserMessage,
|
|
2555
|
+
assertAllowedEnvironmentVariableName,
|
|
2556
|
+
assertConfiguredModel,
|
|
2557
|
+
assertPackSandboxImageCompatible,
|
|
2558
|
+
assertWorkspaceDeletable,
|
|
2559
|
+
assertWorkspaceMemberRemovable,
|
|
2560
|
+
buildCapabilityCatalog,
|
|
2561
|
+
buildFleetContextForSession,
|
|
2562
|
+
buildMarketingDailyAnalysisAgentConfig,
|
|
2563
|
+
checkLimit,
|
|
2564
|
+
createAndStartSession,
|
|
2565
|
+
createCatalogItem,
|
|
2566
|
+
createSessionForRequest,
|
|
2567
|
+
createValidatedScheduledTask,
|
|
2568
|
+
disableCapability,
|
|
2569
|
+
discoverMcpRegistryCapabilities,
|
|
2570
|
+
enableCapability,
|
|
2571
|
+
enabledCapabilityMcpToolRefs,
|
|
2572
|
+
getCapabilityPack,
|
|
2573
|
+
hasPermission,
|
|
2574
|
+
isBuiltInCapabilityPack,
|
|
2575
|
+
isUserMember,
|
|
2576
|
+
listCapabilityPacks,
|
|
2577
|
+
listFleet,
|
|
2578
|
+
listWorkspaceCapabilityPacks,
|
|
2579
|
+
manualScheduledTaskTriggerUsageKey,
|
|
2580
|
+
manualScheduledTaskTriggerWorkflowId,
|
|
2581
|
+
memberCanAdminister,
|
|
2582
|
+
mergeResourceRefs,
|
|
2583
|
+
mergeToolRefs,
|
|
2584
|
+
normalizeResources,
|
|
2585
|
+
officialMcpRegistryUrl,
|
|
2586
|
+
postUserMessageTurn,
|
|
2587
|
+
provisionSandbox,
|
|
2588
|
+
reasoningEffortForSession,
|
|
2589
|
+
recordEnvironmentAuditEvent,
|
|
2590
|
+
recordWorkspaceUsage,
|
|
2591
|
+
relayConfigFromSettings,
|
|
2592
|
+
relayDialBaseFromSettings,
|
|
2593
|
+
requireAccessContext,
|
|
2594
|
+
requireAccessGrant,
|
|
2595
|
+
requireEnvironmentEncryption,
|
|
2596
|
+
requireEnvironmentForApi,
|
|
2597
|
+
requireLimit,
|
|
2598
|
+
requirePermission,
|
|
2599
|
+
requireQueuedTurnForApi,
|
|
2600
|
+
requireScheduledTaskForApi,
|
|
2601
|
+
resolveCapabilityPack,
|
|
2602
|
+
resolveMemberSubjectId,
|
|
2603
|
+
restoreScheduledTask,
|
|
2604
|
+
routingEnabled,
|
|
2605
|
+
runOnSandbox,
|
|
2606
|
+
scheduledTaskTemporalScheduleId,
|
|
2607
|
+
scheduledTaskToolsProvided,
|
|
2608
|
+
scheduledTaskTriggerToken,
|
|
2609
|
+
settingsWithEnabledCapabilityMcpServers,
|
|
2610
|
+
settingsWithMcpCapabilityServers,
|
|
2611
|
+
stableJson,
|
|
2612
|
+
swapActiveSandbox,
|
|
2613
|
+
syncCreatedScheduledTask,
|
|
2614
|
+
syncUpdatedScheduledTask,
|
|
2615
|
+
updateSessionTitle,
|
|
2616
|
+
validateEnvironmentAttachment,
|
|
2617
|
+
validateFileResources,
|
|
2618
|
+
validateGitHubRepositorySelection,
|
|
2619
|
+
validateGitHubRepositorySelectionShape,
|
|
2620
|
+
validateMcpCapabilityConnection,
|
|
2621
|
+
validateToolRefs,
|
|
2622
|
+
validatedScheduledTaskUpdate,
|
|
2623
|
+
withDefaultEnabledCapabilityMcpTools,
|
|
2624
|
+
workflowIdForSession,
|
|
2625
|
+
wrapChannelABoxWithRouting
|
|
2626
|
+
};
|
|
2627
|
+
//# sourceMappingURL=index.js.map
|