@opengeni/core 0.4.6 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +179 -30
- package/dist/index.js +1318 -475
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
- package/src/access/index.ts +132 -57
- package/src/billing/limits.ts +76 -34
- package/src/dependencies.ts +76 -14
- package/src/domain/capabilities.ts +380 -181
- package/src/domain/environments.ts +58 -38
- package/src/domain/packs.ts +49 -16
- package/src/domain/resources.ts +71 -28
- package/src/domain/scheduled-tasks.ts +107 -41
- package/src/domain/sessions.ts +514 -258
- package/src/domain/workspace-members.ts +6 -2
- package/src/index.ts +1 -0
- package/src/rigs/index.ts +540 -0
- package/src/sandbox/fleet.ts +97 -18
- package/src/sandbox/routing.ts +6 -1
- package/src/sandbox-types.ts +17 -5
package/src/domain/sessions.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
|
|
2
|
-
import { configuredAllowedModels, type Settings } from "@opengeni/config";
|
|
2
|
+
import { configuredAllowedModels, policyProviderIdForModel, type Settings } from "@opengeni/config";
|
|
3
3
|
import {
|
|
4
4
|
CreateSessionRequest,
|
|
5
|
+
evaluateWorkspaceModelPolicy,
|
|
5
6
|
reasoningEffortForMetadata,
|
|
6
7
|
type AccessGrant,
|
|
7
8
|
type GoalSpec,
|
|
@@ -17,36 +18,47 @@ import {
|
|
|
17
18
|
type ToolRef,
|
|
18
19
|
} from "@opengeni/contracts";
|
|
19
20
|
import {
|
|
20
|
-
appendSessionEventsWithLockedSessionUpdate,
|
|
21
21
|
createSession,
|
|
22
|
-
createSessionGoal,
|
|
23
22
|
createSessionWithIdempotencyKey,
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
enqueueSessionMessageAtomically,
|
|
24
|
+
encryptVariableSetValue,
|
|
26
25
|
getAnySessionInGroup,
|
|
27
26
|
getEnrollment,
|
|
28
|
-
|
|
27
|
+
getRig,
|
|
28
|
+
getWorkspaceDefaultRigId,
|
|
29
|
+
listDistinctVariableSetIdsInGroup,
|
|
30
|
+
listDistinctRigVersionIdsInGroup,
|
|
29
31
|
getSandbox,
|
|
30
32
|
getSession,
|
|
31
33
|
getSessionByCreateIdempotencyKey,
|
|
34
|
+
getSessionLineage,
|
|
32
35
|
getSessionTurn,
|
|
36
|
+
getWorkspaceModelPolicy,
|
|
37
|
+
initializeSessionStartAtomically,
|
|
33
38
|
requireSession,
|
|
34
|
-
setTemporalWorkflowId,
|
|
35
39
|
updateSessionTitle as updateSessionTitleRow,
|
|
36
40
|
type CreateSessionMcpServerInput,
|
|
37
41
|
type Database,
|
|
38
42
|
type UpdateSessionMcpServerCredentialsInput,
|
|
43
|
+
SessionQueueConflictError,
|
|
39
44
|
} from "@opengeni/db";
|
|
40
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
appendAndPublishEvents,
|
|
47
|
+
publishDurableSessionEvents,
|
|
48
|
+
type EventBus,
|
|
49
|
+
} from "@opengeni/events";
|
|
41
50
|
import { HTTPException } from "hono/http-exception";
|
|
42
51
|
import { hasPermission, requirePermission } from "../access";
|
|
43
52
|
import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
|
|
44
|
-
import type {
|
|
53
|
+
import type {
|
|
54
|
+
AcceptSessionUserMessageDependencies,
|
|
55
|
+
ApiRouteDeps,
|
|
56
|
+
SessionWorkflowClient,
|
|
57
|
+
} from "../dependencies";
|
|
45
58
|
import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
|
|
46
59
|
import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
|
|
47
|
-
import {
|
|
60
|
+
import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
|
|
48
61
|
import {
|
|
49
|
-
mergeResourceRefs,
|
|
50
62
|
mergeToolRefs,
|
|
51
63
|
normalizeResources,
|
|
52
64
|
validateFileResources,
|
|
@@ -67,13 +79,19 @@ type ValidatedSessionMcpServers = {
|
|
|
67
79
|
metadata: SessionMcpServerMetadata[];
|
|
68
80
|
};
|
|
69
81
|
|
|
70
|
-
function normalizedSessionMcpCredentialHeaders(
|
|
82
|
+
function normalizedSessionMcpCredentialHeaders(
|
|
83
|
+
headers: Record<string, string> | undefined,
|
|
84
|
+
): Record<string, string> {
|
|
71
85
|
if (!headers) {
|
|
72
86
|
return {};
|
|
73
87
|
}
|
|
74
|
-
const entries = Object.entries(headers)
|
|
88
|
+
const entries = Object.entries(headers)
|
|
89
|
+
.map(([name, value]) => [name.trim(), value] as const)
|
|
90
|
+
.filter(([name]) => name.length > 0);
|
|
75
91
|
if (entries.length > maxSessionMcpCredentialHeaders) {
|
|
76
|
-
throw new HTTPException(422, {
|
|
92
|
+
throw new HTTPException(422, {
|
|
93
|
+
message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers`,
|
|
94
|
+
});
|
|
77
95
|
}
|
|
78
96
|
const seen = new Set<string>();
|
|
79
97
|
for (const [name, value] of entries) {
|
|
@@ -86,12 +104,16 @@ function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> |
|
|
|
86
104
|
}
|
|
87
105
|
seen.add(lower);
|
|
88
106
|
if (value.length === 0 || value.length > maxSessionMcpCredentialHeaderValueLength) {
|
|
89
|
-
throw new HTTPException(422, {
|
|
107
|
+
throw new HTTPException(422, {
|
|
108
|
+
message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters`,
|
|
109
|
+
});
|
|
90
110
|
}
|
|
91
111
|
// RFC 9110 §5.5: field values are HTAB / printable characters.
|
|
92
112
|
// eslint-disable-next-line no-control-regex
|
|
93
113
|
if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
|
|
94
|
-
throw new HTTPException(422, {
|
|
114
|
+
throw new HTTPException(422, {
|
|
115
|
+
message: `credential header ${name} contains forbidden control characters`,
|
|
116
|
+
});
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
119
|
return Object.fromEntries(entries);
|
|
@@ -109,7 +131,9 @@ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpS
|
|
|
109
131
|
};
|
|
110
132
|
}
|
|
111
133
|
|
|
112
|
-
function mcpServerConfigFromMetadata(
|
|
134
|
+
function mcpServerConfigFromMetadata(
|
|
135
|
+
server: SessionMcpServerMetadata,
|
|
136
|
+
): Settings["mcpServers"][number] {
|
|
113
137
|
return {
|
|
114
138
|
id: server.id,
|
|
115
139
|
...(server.name ? { name: server.name } : {}),
|
|
@@ -118,21 +142,24 @@ function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings
|
|
|
118
142
|
};
|
|
119
143
|
}
|
|
120
144
|
|
|
121
|
-
function settingsWithSessionMcpServerConfigs(
|
|
145
|
+
function settingsWithSessionMcpServerConfigs(
|
|
146
|
+
settings: Settings,
|
|
147
|
+
servers: Settings["mcpServers"],
|
|
148
|
+
): Settings {
|
|
122
149
|
if (servers.length === 0) {
|
|
123
150
|
return settings;
|
|
124
151
|
}
|
|
125
152
|
const sessionIds = new Set(servers.map((server) => server.id));
|
|
126
153
|
return {
|
|
127
154
|
...settings,
|
|
128
|
-
mcpServers: [
|
|
129
|
-
...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
|
|
130
|
-
...servers,
|
|
131
|
-
],
|
|
155
|
+
mcpServers: [...settings.mcpServers.filter((server) => !sessionIds.has(server.id)), ...servers],
|
|
132
156
|
};
|
|
133
157
|
}
|
|
134
158
|
|
|
135
|
-
export function settingsWithSessionMcpServerMetadata(
|
|
159
|
+
export function settingsWithSessionMcpServerMetadata(
|
|
160
|
+
settings: Settings,
|
|
161
|
+
servers: SessionMcpServerMetadata[],
|
|
162
|
+
): Settings {
|
|
136
163
|
return settingsWithSessionMcpServerConfigs(settings, servers.map(mcpServerConfigFromMetadata));
|
|
137
164
|
}
|
|
138
165
|
|
|
@@ -145,7 +172,7 @@ function validateSessionMcpServersForCreate(
|
|
|
145
172
|
return { runtimeServers: [], dbServers: [], metadata: [] };
|
|
146
173
|
}
|
|
147
174
|
requirePermission(grant, "mcp_servers:attach");
|
|
148
|
-
const encryptionKey =
|
|
175
|
+
const encryptionKey = requireVariableSetEncryption(settings);
|
|
149
176
|
const existingIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
150
177
|
const seenIds = new Set<string>();
|
|
151
178
|
const runtimeServers: Settings["mcpServers"] = [];
|
|
@@ -161,7 +188,10 @@ function validateSessionMcpServersForCreate(
|
|
|
161
188
|
}
|
|
162
189
|
const headers = normalizedSessionMcpCredentialHeaders(server.headers);
|
|
163
190
|
const headersEncrypted = Object.fromEntries(
|
|
164
|
-
Object.entries(headers).map(([name, value]) => [
|
|
191
|
+
Object.entries(headers).map(([name, value]) => [
|
|
192
|
+
name,
|
|
193
|
+
encryptVariableSetValue(encryptionKey, value),
|
|
194
|
+
]),
|
|
165
195
|
);
|
|
166
196
|
runtimeServers.push(mcpServerConfigFromInput(server));
|
|
167
197
|
dbServers.push({
|
|
@@ -195,12 +225,14 @@ function validateSessionMcpCredentialUpdates(input: {
|
|
|
195
225
|
return [];
|
|
196
226
|
}
|
|
197
227
|
requirePermission(input.grant, "mcp_servers:attach");
|
|
198
|
-
const encryptionKey =
|
|
228
|
+
const encryptionKey = requireVariableSetEncryption(input.settings);
|
|
199
229
|
const knownIds = new Set(input.session.mcpServers.map((server) => server.id));
|
|
200
230
|
const seenIds = new Set<string>();
|
|
201
231
|
const encryptedUpdates = input.updates.map((update) => {
|
|
202
232
|
if (seenIds.has(update.id)) {
|
|
203
|
-
throw new HTTPException(422, {
|
|
233
|
+
throw new HTTPException(422, {
|
|
234
|
+
message: `duplicate session MCP credential update id: ${update.id}`,
|
|
235
|
+
});
|
|
204
236
|
}
|
|
205
237
|
seenIds.add(update.id);
|
|
206
238
|
if (!knownIds.has(update.id)) {
|
|
@@ -210,7 +242,10 @@ function validateSessionMcpCredentialUpdates(input: {
|
|
|
210
242
|
return {
|
|
211
243
|
id: update.id,
|
|
212
244
|
headersEncrypted: Object.fromEntries(
|
|
213
|
-
Object.entries(headers).map(([name, value]) => [
|
|
245
|
+
Object.entries(headers).map(([name, value]) => [
|
|
246
|
+
name,
|
|
247
|
+
encryptVariableSetValue(encryptionKey, value),
|
|
248
|
+
]),
|
|
214
249
|
),
|
|
215
250
|
};
|
|
216
251
|
});
|
|
@@ -232,7 +267,12 @@ export async function createAndStartSession(input: {
|
|
|
232
267
|
sandboxBackend: Settings["sandboxBackend"];
|
|
233
268
|
metadata: Record<string, unknown>;
|
|
234
269
|
// Names/ids only; the session.created payload never carries variable values.
|
|
235
|
-
|
|
270
|
+
variableSet?: { id: string; name: string } | null;
|
|
271
|
+
// The rig + frozen active rig version resolved at create (M3). Both null ⇒ a
|
|
272
|
+
// rig-less session (byte-for-byte today's behavior). Frozen here so a later
|
|
273
|
+
// rig promote never moves an existing session's version.
|
|
274
|
+
rigId?: string | null;
|
|
275
|
+
rigVersionId?: string | null;
|
|
236
276
|
goal?: GoalSpec | null;
|
|
237
277
|
// Per-session agent persona/system instructions (org-visible metadata, not a
|
|
238
278
|
// secret). Persisted on the session row and composed system-level AFTER the
|
|
@@ -251,8 +291,8 @@ export async function createAndStartSession(input: {
|
|
|
251
291
|
parentSessionId?: string | null;
|
|
252
292
|
// Workspace-scoped CREATE idempotency key. When present, a double-fire with
|
|
253
293
|
// the same key (sequential retry OR concurrent race) collapses to a single
|
|
254
|
-
// session
|
|
255
|
-
//
|
|
294
|
+
// session. Every caller repairs or re-delivers the winner's one atomic start;
|
|
295
|
+
// the durable initializer prevents duplicate events or turns.
|
|
256
296
|
createIdempotencyKey?: string | null;
|
|
257
297
|
// The shared-sandbox group this session's box joins (addendum 05 §D). Null/
|
|
258
298
|
// omitted ⇒ a singleton group (the new row's own id, today's 1:1 behavior); a
|
|
@@ -281,14 +321,23 @@ export async function createAndStartSession(input: {
|
|
|
281
321
|
// Fast path with a key: return a session already created under this key
|
|
282
322
|
// (the sequential retry / double-submit case) without inserting again.
|
|
283
323
|
if (input.createIdempotencyKey) {
|
|
284
|
-
const existing = await getSessionByCreateIdempotencyKey(
|
|
324
|
+
const existing = await getSessionByCreateIdempotencyKey(
|
|
325
|
+
input.db,
|
|
326
|
+
input.workspaceId,
|
|
327
|
+
input.createIdempotencyKey,
|
|
328
|
+
);
|
|
285
329
|
if (existing) {
|
|
286
|
-
return
|
|
330
|
+
return await finishStartSession(
|
|
331
|
+
existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
|
|
332
|
+
existing,
|
|
333
|
+
);
|
|
287
334
|
}
|
|
288
335
|
// No prior session: insert under the key, racing concurrent creates. The
|
|
289
336
|
// partial unique index lets exactly one insert win; a loser gets back the
|
|
290
|
-
// winner's row with created=false
|
|
291
|
-
//
|
|
337
|
+
// winner's row with created=false. Both callers may enter the idempotent
|
|
338
|
+
// initializer; exactly one creates the first events/turn. Each retry
|
|
339
|
+
// advances the coalesced wake revision so an in-flight stale delivery can
|
|
340
|
+
// never acknowledge work committed by the other caller.
|
|
292
341
|
const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
|
|
293
342
|
accountId: input.accountId,
|
|
294
343
|
workspaceId: input.workspaceId,
|
|
@@ -298,7 +347,9 @@ export async function createAndStartSession(input: {
|
|
|
298
347
|
metadata: sessionMetadata,
|
|
299
348
|
model: input.model,
|
|
300
349
|
sandboxBackend: input.sandboxBackend,
|
|
301
|
-
|
|
350
|
+
variableSetId: input.variableSet?.id ?? null,
|
|
351
|
+
rigId: input.rigId ?? null,
|
|
352
|
+
rigVersionId: input.rigVersionId ?? null,
|
|
302
353
|
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
303
354
|
instructions: input.instructions ?? null,
|
|
304
355
|
parentSessionId: input.parentSessionId ?? null,
|
|
@@ -308,7 +359,10 @@ export async function createAndStartSession(input: {
|
|
|
308
359
|
mcpServers: input.mcpServers ?? [],
|
|
309
360
|
});
|
|
310
361
|
if (!created) {
|
|
311
|
-
return
|
|
362
|
+
return await finishStartSession(
|
|
363
|
+
keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
|
|
364
|
+
keyed,
|
|
365
|
+
);
|
|
312
366
|
}
|
|
313
367
|
return await finishStartSession(input, keyed);
|
|
314
368
|
}
|
|
@@ -321,7 +375,9 @@ export async function createAndStartSession(input: {
|
|
|
321
375
|
metadata: sessionMetadata,
|
|
322
376
|
model: input.model,
|
|
323
377
|
sandboxBackend: input.sandboxBackend,
|
|
324
|
-
|
|
378
|
+
variableSetId: input.variableSet?.id ?? null,
|
|
379
|
+
rigId: input.rigId ?? null,
|
|
380
|
+
rigVersionId: input.rigVersionId ?? null,
|
|
325
381
|
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
326
382
|
instructions: input.instructions ?? null,
|
|
327
383
|
parentSessionId: input.parentSessionId ?? null,
|
|
@@ -333,81 +389,37 @@ export async function createAndStartSession(input: {
|
|
|
333
389
|
}
|
|
334
390
|
|
|
335
391
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* idempotency-key loser/dup can skip it entirely.
|
|
392
|
+
* Complete or repair the post-insert half of {@link createAndStartSession}.
|
|
393
|
+
* All durable initial state is installed by one idempotent transaction; every
|
|
394
|
+
* caller may then advance and deliver the coalesced wake revision without
|
|
395
|
+
* duplicating the goal, events, or first turn.
|
|
341
396
|
*/
|
|
342
|
-
async function finishStartSession(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
text: input.goal.text,
|
|
366
|
-
successCriteria: input.goal.successCriteria ?? null,
|
|
367
|
-
maxAutoContinuations: input.goal.maxAutoContinuations ?? null,
|
|
368
|
-
createdBy: "api",
|
|
369
|
-
})
|
|
370
|
-
: null;
|
|
371
|
-
const initialPayload = {
|
|
372
|
-
text: input.initialMessage,
|
|
373
|
-
...(input.resources.length ? { resources: input.resources } : {}),
|
|
374
|
-
...(input.tools.length ? { tools: input.tools } : {}),
|
|
375
|
-
};
|
|
376
|
-
const events = await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [
|
|
377
|
-
{
|
|
378
|
-
type: "session.created",
|
|
379
|
-
payload: {
|
|
380
|
-
status: "queued",
|
|
381
|
-
...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
|
|
382
|
-
...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
...(goal ? [{
|
|
386
|
-
type: "goal.set" as const,
|
|
387
|
-
payload: {
|
|
388
|
-
goalId: goal.id,
|
|
389
|
-
text: goal.text,
|
|
390
|
-
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
391
|
-
version: goal.version,
|
|
392
|
-
actor: "api",
|
|
393
|
-
replaced: false,
|
|
394
|
-
},
|
|
395
|
-
}] : []),
|
|
396
|
-
{
|
|
397
|
-
type: "user.message",
|
|
398
|
-
payload: initialPayload,
|
|
399
|
-
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
400
|
-
},
|
|
401
|
-
{ type: "session.status.changed", payload: { status: "queued" } },
|
|
402
|
-
]);
|
|
403
|
-
const userEvent = events.find((event) => event.type === "user.message");
|
|
404
|
-
if (!userEvent) {
|
|
405
|
-
throw new HTTPException(500, { message: "failed to append initial user event" });
|
|
406
|
-
}
|
|
397
|
+
async function finishStartSession(
|
|
398
|
+
input: {
|
|
399
|
+
db: Database;
|
|
400
|
+
bus: EventBus;
|
|
401
|
+
workflowClient: SessionWorkflowClient;
|
|
402
|
+
initialMessage: string;
|
|
403
|
+
resources: ResourceRef[];
|
|
404
|
+
tools: ToolRef[];
|
|
405
|
+
clientEventId?: string;
|
|
406
|
+
model: string;
|
|
407
|
+
reasoningEffort: Settings["openaiReasoningEffort"];
|
|
408
|
+
sandboxBackend: Settings["sandboxBackend"];
|
|
409
|
+
variableSet?: { id: string; name: string } | null;
|
|
410
|
+
goal?: GoalSpec | null;
|
|
411
|
+
sessionMcpServers?: SessionMcpServerMetadata[];
|
|
412
|
+
seedTargetSandbox?: {
|
|
413
|
+
sandboxId: string;
|
|
414
|
+
settings: Settings;
|
|
415
|
+
workingDir?: string | null;
|
|
416
|
+
} | null;
|
|
417
|
+
},
|
|
418
|
+
session: Session,
|
|
419
|
+
): Promise<Session> {
|
|
407
420
|
// Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
|
|
408
|
-
// the
|
|
409
|
-
//
|
|
410
|
-
// before wakeSessionWorkflow below signals the worker. swapActiveSandbox does
|
|
421
|
+
// the atomic initial turn transaction, so the FIRST turn routes to the chosen
|
|
422
|
+
// machine. swapActiveSandbox does
|
|
411
423
|
// the same ownership+liveness validation as the live swap; an invalid/unowned/
|
|
412
424
|
// offline target FAILS the create (422) — never a silent fall-back to the box.
|
|
413
425
|
if (input.seedTargetSandbox) {
|
|
@@ -437,29 +449,40 @@ async function finishStartSession(input: {
|
|
|
437
449
|
});
|
|
438
450
|
}
|
|
439
451
|
}
|
|
440
|
-
const
|
|
441
|
-
await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
|
|
442
|
-
const turn = await enqueueSessionTurn(input.db, {
|
|
452
|
+
const started = await initializeSessionStartAtomically(input.db, {
|
|
443
453
|
accountId: session.accountId,
|
|
444
454
|
workspaceId: session.workspaceId,
|
|
445
455
|
sessionId: session.id,
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
+
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
457
|
+
reasoningEffortFallback: input.reasoningEffort,
|
|
458
|
+
createdEventPayload: {
|
|
459
|
+
...(input.variableSet
|
|
460
|
+
? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
|
|
461
|
+
: {}),
|
|
462
|
+
...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
|
|
463
|
+
},
|
|
464
|
+
goal: input.goal
|
|
465
|
+
? {
|
|
466
|
+
text: input.goal.text,
|
|
467
|
+
...(input.goal.successCriteria !== undefined
|
|
468
|
+
? { successCriteria: input.goal.successCriteria }
|
|
469
|
+
: {}),
|
|
470
|
+
...(input.goal.maxAutoContinuations !== undefined
|
|
471
|
+
? { maxAutoContinuations: input.goal.maxAutoContinuations }
|
|
472
|
+
: {}),
|
|
473
|
+
}
|
|
474
|
+
: null,
|
|
456
475
|
});
|
|
457
|
-
await
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
476
|
+
await publishDurableSessionEvents(input.bus, session.workspaceId, session.id, started.events);
|
|
477
|
+
if (started.workflowWakeRevision !== null) {
|
|
478
|
+
await input.workflowClient.wakeSessionWorkflow({
|
|
479
|
+
accountId: session.accountId,
|
|
480
|
+
workspaceId: session.workspaceId,
|
|
481
|
+
sessionId: session.id,
|
|
482
|
+
workflowId: started.temporalWorkflowId,
|
|
483
|
+
wakeRevision: started.workflowWakeRevision,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
463
486
|
return await requireSession(input.db, session.workspaceId, session.id);
|
|
464
487
|
}
|
|
465
488
|
|
|
@@ -502,18 +525,64 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
|
|
|
502
525
|
throw new HTTPException(422, { message: `model is not available: ${model}` });
|
|
503
526
|
}
|
|
504
527
|
|
|
505
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Reject a model the WORKSPACE's model policy blocks, at the same choke points
|
|
530
|
+
* as assertConfiguredModel — a 422 at the edge instead of a queued turn the
|
|
531
|
+
* worker's authoritative post-resolution gate would fail. `model` is the
|
|
532
|
+
* EFFECTIVE value the caller is about to persist: pass the explicit value at
|
|
533
|
+
* message/turn-update/scheduled-task edges (omitted inherits an
|
|
534
|
+
* already-validated stored default), but at session CREATION pass
|
|
535
|
+
* `payload.model ?? settings.openaiModel` — an omitted model stamps the
|
|
536
|
+
* deployment default onto the session, and under a restricted policy that
|
|
537
|
+
* default may be exactly the provider the policy exists to block.
|
|
538
|
+
*/
|
|
539
|
+
export async function assertWorkspaceModelPolicyAllows(
|
|
540
|
+
db: Database,
|
|
541
|
+
settings: Settings,
|
|
542
|
+
workspaceId: string,
|
|
543
|
+
model: string | null | undefined,
|
|
544
|
+
): Promise<void> {
|
|
545
|
+
if (model === null || model === undefined) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const policy = await getWorkspaceModelPolicy(db, workspaceId);
|
|
549
|
+
if (!policy) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const providerId = policyProviderIdForModel(settings, model);
|
|
553
|
+
const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
|
|
554
|
+
if (!verdict.allowed) {
|
|
555
|
+
throw new HTTPException(422, {
|
|
556
|
+
message:
|
|
557
|
+
verdict.reason === "provider"
|
|
558
|
+
? `model "${model}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
|
|
559
|
+
: `model "${model}" is not allowed by this workspace's model policy`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function requireQueuedTurnForApi(
|
|
565
|
+
db: Database,
|
|
566
|
+
workspaceId: string,
|
|
567
|
+
sessionId: string,
|
|
568
|
+
turnId: string,
|
|
569
|
+
): Promise<SessionTurn> {
|
|
506
570
|
const turn = await getSessionTurn(db, workspaceId, turnId);
|
|
507
571
|
if (!turn || turn.sessionId !== sessionId) {
|
|
508
572
|
throw new HTTPException(404, { message: "session turn not found" });
|
|
509
573
|
}
|
|
510
574
|
if (turn.status !== "queued") {
|
|
511
|
-
throw new HTTPException(409, {
|
|
575
|
+
throw new HTTPException(409, {
|
|
576
|
+
message: `turn is ${turn.status}; only queued turns can be changed`,
|
|
577
|
+
});
|
|
512
578
|
}
|
|
513
579
|
return turn;
|
|
514
580
|
}
|
|
515
581
|
|
|
516
|
-
export function reasoningEffortForSession(
|
|
582
|
+
export function reasoningEffortForSession(
|
|
583
|
+
metadata: Record<string, unknown>,
|
|
584
|
+
fallback: Settings["openaiReasoningEffort"],
|
|
585
|
+
): Settings["openaiReasoningEffort"] {
|
|
517
586
|
return reasoningEffortForMetadata(metadata, fallback);
|
|
518
587
|
}
|
|
519
588
|
|
|
@@ -527,7 +596,7 @@ export function reasoningEffortForSession(metadata: Record<string, unknown>, fal
|
|
|
527
596
|
export async function postUserMessageTurn(input: {
|
|
528
597
|
db: Database;
|
|
529
598
|
bus: EventBus;
|
|
530
|
-
workflowClient: SessionWorkflowClient
|
|
599
|
+
workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow" | "signalSessionControl">;
|
|
531
600
|
settings: Settings;
|
|
532
601
|
accountId: string;
|
|
533
602
|
workspaceId: string;
|
|
@@ -539,6 +608,11 @@ export async function postUserMessageTurn(input: {
|
|
|
539
608
|
reasoningEffort?: Settings["openaiReasoningEffort"] | null;
|
|
540
609
|
clientEventId?: string;
|
|
541
610
|
mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
|
|
611
|
+
delivery?: "queue" | "steer";
|
|
612
|
+
origin?: "human" | "operator";
|
|
613
|
+
actor?: string;
|
|
614
|
+
expectedControlGeneration?: number;
|
|
615
|
+
expectedWorkspaceInferenceGeneration?: number;
|
|
542
616
|
}): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
|
|
543
617
|
const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
|
|
544
618
|
const requestedModel = input.model ?? null;
|
|
@@ -546,85 +620,79 @@ export async function postUserMessageTurn(input: {
|
|
|
546
620
|
// Reject an explicit per-message model the host does not expose; an omitted
|
|
547
621
|
// model inherits the session's model downstream (always a configured id).
|
|
548
622
|
assertConfiguredModel(settings, requestedModel);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
623
|
+
await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, requestedModel);
|
|
624
|
+
let result;
|
|
625
|
+
try {
|
|
626
|
+
result = await enqueueSessionMessageAtomically(db, {
|
|
627
|
+
accountId,
|
|
628
|
+
workspaceId,
|
|
629
|
+
sessionId,
|
|
630
|
+
actor: input.actor ?? accountId,
|
|
631
|
+
origin: input.origin ?? "human",
|
|
632
|
+
text: input.text,
|
|
633
|
+
resources: input.resources,
|
|
634
|
+
tools: input.tools,
|
|
635
|
+
model: requestedModel,
|
|
636
|
+
reasoningEffort: requestedReasoningEffort,
|
|
637
|
+
clientEventId: input.clientEventId ?? null,
|
|
638
|
+
mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
|
|
639
|
+
delivery: input.delivery ?? "queue",
|
|
640
|
+
...(input.expectedControlGeneration !== undefined
|
|
641
|
+
? { expectedControlGeneration: input.expectedControlGeneration }
|
|
642
|
+
: {}),
|
|
643
|
+
...(input.expectedWorkspaceInferenceGeneration !== undefined
|
|
644
|
+
? {
|
|
645
|
+
expectedWorkspaceInferenceGeneration: input.expectedWorkspaceInferenceGeneration,
|
|
646
|
+
}
|
|
647
|
+
: {}),
|
|
648
|
+
reasoningEffortFallback: settings.openaiReasoningEffort,
|
|
649
|
+
});
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error instanceof SessionQueueConflictError) {
|
|
652
|
+
throw new HTTPException(409, { message: error.message });
|
|
559
653
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
: { servers: [], missingIds: [] };
|
|
563
|
-
if (mcpCredentialUpdates.missingIds.length > 0) {
|
|
564
|
-
throw new HTTPException(422, { message: `unknown session MCP server id: ${mcpCredentialUpdates.missingIds[0]}` });
|
|
654
|
+
if (error instanceof Error && error.message.includes("cancelled")) {
|
|
655
|
+
throw new HTTPException(409, { message: error.message });
|
|
565
656
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const session = await requireSession(db, workspaceId, sessionId);
|
|
601
|
-
const turn = await enqueueSessionTurn(db, {
|
|
602
|
-
accountId,
|
|
603
|
-
workspaceId,
|
|
604
|
-
sessionId,
|
|
605
|
-
triggerEventId: accepted.id,
|
|
606
|
-
temporalWorkflowId: workflowId,
|
|
607
|
-
source: "user",
|
|
608
|
-
prompt: input.text,
|
|
609
|
-
resources: input.resources,
|
|
610
|
-
tools: input.tools,
|
|
611
|
-
model: requestedModel ?? session.model,
|
|
612
|
-
reasoningEffort: requestedReasoningEffort ?? reasoningEffortForSession(session.metadata, settings.openaiReasoningEffort),
|
|
613
|
-
sandboxBackend: session.sandboxBackend,
|
|
614
|
-
metadata: {},
|
|
615
|
-
});
|
|
616
|
-
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
|
|
617
|
-
type: "turn.queued",
|
|
618
|
-
turnId: turn.id,
|
|
619
|
-
payload: { turnId: turn.id, triggerEventId: accepted.id, source: turn.source },
|
|
620
|
-
}]);
|
|
621
|
-
await workflowClient.wakeSessionWorkflow({ accountId, workspaceId, sessionId, workflowId });
|
|
622
|
-
return { accepted, turn };
|
|
657
|
+
if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
|
|
658
|
+
throw new HTTPException(422, { message: error.message });
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
await bus.publish(workspaceId, sessionId, result.events);
|
|
663
|
+
if (result.shouldSignalControl && result.controlEvent) {
|
|
664
|
+
if (result.workflowWakeRevision === null) {
|
|
665
|
+
throw new Error("Steer control has no workflow wake revision");
|
|
666
|
+
}
|
|
667
|
+
await workflowClient.signalSessionControl({
|
|
668
|
+
accountId,
|
|
669
|
+
workspaceId,
|
|
670
|
+
sessionId,
|
|
671
|
+
eventId: result.controlEvent.id,
|
|
672
|
+
workflowId: result.temporalWorkflowId,
|
|
673
|
+
workflowWakeRevision: result.workflowWakeRevision,
|
|
674
|
+
});
|
|
675
|
+
} else if (result.shouldWake) {
|
|
676
|
+
if (result.workflowWakeRevision === null) {
|
|
677
|
+
throw new Error("Runnable prompt has no workflow wake revision");
|
|
678
|
+
}
|
|
679
|
+
await workflowClient.wakeSessionWorkflow({
|
|
680
|
+
accountId,
|
|
681
|
+
workspaceId,
|
|
682
|
+
sessionId,
|
|
683
|
+
workflowId: result.temporalWorkflowId,
|
|
684
|
+
wakeRevision: result.workflowWakeRevision,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
accepted: result.accepted,
|
|
689
|
+
turn: result.turn,
|
|
690
|
+
};
|
|
623
691
|
}
|
|
624
692
|
|
|
625
693
|
/**
|
|
626
694
|
* Full create-session flow shared by `POST /sessions` and the first-party MCP
|
|
627
|
-
* `session_create` tool: payload validation, resource/tool/
|
|
695
|
+
* `session_create` tool: payload validation, resource/tool/variableSet
|
|
628
696
|
* checks, usage limits, session start, and usage recording. `rawPayload` is
|
|
629
697
|
* the unparsed request body so absent-vs-empty `tools` keeps its meaning
|
|
630
698
|
* (absent applies the workspace's default capability MCP tools).
|
|
@@ -637,9 +705,20 @@ export async function createSessionForRequest(
|
|
|
637
705
|
): Promise<Session> {
|
|
638
706
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
639
707
|
const payload = CreateSessionRequest.parse(rawPayload);
|
|
640
|
-
const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
641
|
-
|
|
642
|
-
|
|
708
|
+
const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
709
|
+
db,
|
|
710
|
+
workspaceId,
|
|
711
|
+
settings,
|
|
712
|
+
);
|
|
713
|
+
const sessionMcpServers = validateSessionMcpServersForCreate(
|
|
714
|
+
capabilityRuntimeSettings,
|
|
715
|
+
grant,
|
|
716
|
+
payload.mcpServers,
|
|
717
|
+
);
|
|
718
|
+
const runtimeSettings = settingsWithSessionMcpServerConfigs(
|
|
719
|
+
capabilityRuntimeSettings,
|
|
720
|
+
sessionMcpServers.runtimeServers,
|
|
721
|
+
);
|
|
643
722
|
const resources = normalizeResources(payload.resources);
|
|
644
723
|
const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
|
|
645
724
|
const defaultedTools = hasOwnProperty(rawPayload, "tools")
|
|
@@ -647,7 +726,7 @@ export async function createSessionForRequest(
|
|
|
647
726
|
: withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
|
|
648
727
|
// The first-party MCP server is attached to EVERY session. It hosts the
|
|
649
728
|
// session's own metadata tool (set_session_title) + goal tools, and — only
|
|
650
|
-
// when the grant carries the permission — the orchestration/
|
|
729
|
+
// when the grant carries the permission — the orchestration/variableSet/
|
|
651
730
|
// github tools. Capability is gated per-tool by permission, never by whether
|
|
652
731
|
// the server is attached, so a bare chat still gets titling while the
|
|
653
732
|
// dangerous tools stay off by default.
|
|
@@ -657,13 +736,57 @@ export async function createSessionForRequest(
|
|
|
657
736
|
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
658
737
|
}
|
|
659
738
|
await validateFileResources(db, workspaceId, resources);
|
|
660
|
-
//
|
|
661
|
-
// (
|
|
739
|
+
// VariableSet attachment requires variable-sets:use on the calling grant
|
|
740
|
+
// (validateVariableSetAttachment enforces it), preserving the invariant
|
|
662
741
|
// that sandboxed agents cannot self-attach workspace secrets.
|
|
663
|
-
const
|
|
664
|
-
? await
|
|
742
|
+
const variableSet = payload.variableSetId
|
|
743
|
+
? await validateVariableSetAttachment(
|
|
744
|
+
{ settings, db },
|
|
745
|
+
grant,
|
|
746
|
+
workspaceId,
|
|
747
|
+
payload.variableSetId,
|
|
748
|
+
)
|
|
665
749
|
: null;
|
|
750
|
+
// RIG BINDING (M3). Resolve the rig this session rides — the EXPLICIT payload
|
|
751
|
+
// rigId when given, else the workspace default rig (workspaces.default_rig_id)
|
|
752
|
+
// — and FREEZE both the rig id and its currently-ACTIVE version onto the row.
|
|
753
|
+
// The session then rides that exact version for its whole life; a later
|
|
754
|
+
// promote never moves it. Rig-less (both null) when neither resolves, which is
|
|
755
|
+
// byte-for-byte today's behavior (zero extra work, zero row change).
|
|
756
|
+
// - An EXPLICIT unknown/inactive rigId is a caller error → 422.
|
|
757
|
+
// - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
|
|
758
|
+
// active version) degrades SILENTLY to rig-less: an operator-side default
|
|
759
|
+
// must never brick every create in the workspace.
|
|
760
|
+
const requestedRigId = payload.rigId ?? (await getWorkspaceDefaultRigId(db, workspaceId));
|
|
761
|
+
let frozenRigId: string | null = null;
|
|
762
|
+
let frozenRigVersionId: string | null = null;
|
|
763
|
+
if (requestedRigId) {
|
|
764
|
+
const rig = await getRig(db, workspaceId, requestedRigId);
|
|
765
|
+
if (!rig || !rig.activeVersion) {
|
|
766
|
+
if (payload.rigId) {
|
|
767
|
+
throw new HTTPException(422, {
|
|
768
|
+
message: rig
|
|
769
|
+
? `rig ${payload.rigId} has no active version to bind`
|
|
770
|
+
: `unknown rigId: ${payload.rigId}`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
// else: workspace-default fallback that no longer resolves → rig-less.
|
|
774
|
+
} else {
|
|
775
|
+
frozenRigId = rig.id;
|
|
776
|
+
frozenRigVersionId = rig.activeVersion.id;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
666
779
|
assertConfiguredModel(settings, payload.model);
|
|
780
|
+
// Session creation persists the EFFECTIVE model — an omitted payload.model
|
|
781
|
+
// stamps the deployment default onto the session — so the policy must vet
|
|
782
|
+
// that effective value, not just explicit ones (a restricted workspace's
|
|
783
|
+
// default-model session would otherwise be born blocked).
|
|
784
|
+
await assertWorkspaceModelPolicyAllows(
|
|
785
|
+
db,
|
|
786
|
+
settings,
|
|
787
|
+
workspaceId,
|
|
788
|
+
payload.model ?? settings.openaiModel,
|
|
789
|
+
);
|
|
667
790
|
const model = payload.model ?? settings.openaiModel;
|
|
668
791
|
const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
|
|
669
792
|
// A session's first-party MCP token can carry a non-default permission set
|
|
@@ -674,11 +797,16 @@ export async function createSessionForRequest(
|
|
|
674
797
|
if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
|
|
675
798
|
// An empty set would sign an unusable zero-permission token; the default
|
|
676
799
|
// worker set is expressed by omitting the field.
|
|
677
|
-
throw new HTTPException(422, {
|
|
800
|
+
throw new HTTPException(422, {
|
|
801
|
+
message:
|
|
802
|
+
"firstPartyMcpPermissions must not be empty; omit it for the default worker permission set",
|
|
803
|
+
});
|
|
678
804
|
}
|
|
679
805
|
for (const permission of firstPartyMcpPermissions ?? []) {
|
|
680
806
|
if (!hasPermission(grant.permissions, permission)) {
|
|
681
|
-
throw new HTTPException(403, {
|
|
807
|
+
throw new HTTPException(403, {
|
|
808
|
+
message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}`,
|
|
809
|
+
});
|
|
682
810
|
}
|
|
683
811
|
}
|
|
684
812
|
// Invariant: a goal-bearing session always carries goals:manage in its
|
|
@@ -690,7 +818,11 @@ export async function createSessionForRequest(
|
|
|
690
818
|
// spawned session itself via the worker-signed sessionId claim, so a
|
|
691
819
|
// worker managing its OWN goal is not an escalation of the spawner's
|
|
692
820
|
// authority.
|
|
693
|
-
if (
|
|
821
|
+
if (
|
|
822
|
+
payload.goal &&
|
|
823
|
+
firstPartyMcpPermissions &&
|
|
824
|
+
!firstPartyMcpPermissions.includes("goals:manage")
|
|
825
|
+
) {
|
|
694
826
|
firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
|
|
695
827
|
}
|
|
696
828
|
// Parent linkage: a worker is linked to its manager ONLY from the
|
|
@@ -704,7 +836,10 @@ export async function createSessionForRequest(
|
|
|
704
836
|
// its completion wake injects a user.message + queued turn into that session
|
|
705
837
|
// without holding sessions:control on it (a cross-session write escalation).
|
|
706
838
|
// The claim is the only trustworthy parent source.
|
|
707
|
-
const parentSessionId =
|
|
839
|
+
const parentSessionId =
|
|
840
|
+
typeof grant.metadata?.["sessionId"] === "string"
|
|
841
|
+
? (grant.metadata["sessionId"] as string)
|
|
842
|
+
: null;
|
|
708
843
|
// Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
|
|
709
844
|
//
|
|
710
845
|
// The DEFAULT rule is context-dependent and resolved server-side from the
|
|
@@ -724,39 +859,75 @@ export async function createSessionForRequest(
|
|
|
724
859
|
const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
|
|
725
860
|
let sandboxGroupId: string | null = null;
|
|
726
861
|
let inheritedBackend: Session["sandboxBackend"] | undefined;
|
|
727
|
-
// ENV-AWARE GROUPING: under the CURRENT mechanics the workspace
|
|
862
|
+
// ENV-AWARE GROUPING: under the CURRENT mechanics the workspace VariableSet is
|
|
728
863
|
// creation-time box state — the box's manifest env is fixed when it is cold-
|
|
729
864
|
// created, and the SDK's provided-session guard rejects any manifest-env delta
|
|
730
|
-
// at attach. A session carrying a DIFFERENT
|
|
865
|
+
// at attach. A session carrying a DIFFERENT VariableSet than the box it joins
|
|
731
866
|
// is therefore a genuine shared-state conflict TODAY: its first turn on a warm
|
|
732
|
-
// box dies with "Live sandbox sessions cannot change manifest
|
|
733
|
-
// variables" (proven live, sessions 5aee77e9 + 63d18823). Until the
|
|
867
|
+
// box dies with "Live sandbox sessions cannot change manifest variableSet
|
|
868
|
+
// variables" (proven live, sessions 5aee77e9 + 63d18823). Until the VariableSet
|
|
734
869
|
// is evicted from the manifest (per-exec, like the git token), grouping must be
|
|
735
870
|
// env-aware: the INHERITED default falls back to an own box on mismatch (a
|
|
736
871
|
// credentialed worker spawned from a credential-less manager just works), and
|
|
737
|
-
// an EXPLICIT shared/{groupId} request with a mismatched
|
|
872
|
+
// an EXPLICIT shared/{groupId} request with a mismatched VariableSet fails
|
|
738
873
|
// fast at create (422) instead of poisoning the session's first turn.
|
|
739
874
|
// The env conflict is a BOX property, so a boxless group is exempt: a
|
|
740
875
|
// backend:"none" session runs in-process with no sandbox, no manifest, and no
|
|
741
876
|
// provided-session attach — no shared box state exists to conflict, and
|
|
742
877
|
// env-differing spawns from such parents shared safely before the env-aware
|
|
743
878
|
// check. They keep sharing (and keep inheriting "none").
|
|
744
|
-
const
|
|
745
|
-
const
|
|
746
|
-
|
|
879
|
+
const requestedVariableSetId = payload.variableSetId ?? null;
|
|
880
|
+
const variableSetMatchesGroup = (memberVariableSetId: string | null): boolean =>
|
|
881
|
+
memberVariableSetId === requestedVariableSetId;
|
|
882
|
+
// RIG-AWARE GROUPING (M3), the exact sibling of the env-aware gate above: the
|
|
883
|
+
// box's rig-baked setup/tooling is fixed at cold-create, so a session joining a
|
|
884
|
+
// shared box must ride the SAME frozen rig_version_id. A mismatch is a genuine
|
|
885
|
+
// shared-state conflict (the box was set up for a different rig) — the INHERITED
|
|
886
|
+
// default falls back to an own box, an EXPLICIT shared/{groupId} request 422s at
|
|
887
|
+
// create rather than poisoning the first turn on the lease's rig-conflict guard.
|
|
888
|
+
// null on either side = compatible (a rig-less session shares with a rig-less
|
|
889
|
+
// box exactly as today); the boxless backend:'none' exemption is shared with the
|
|
890
|
+
// env gate (no box state to conflict).
|
|
891
|
+
const rigVersionMatchesGroup = (memberRigVersionId: string | null): boolean =>
|
|
892
|
+
memberRigVersionId === frozenRigVersionId;
|
|
747
893
|
if (sandboxChoice === "shared") {
|
|
748
894
|
if (!parentSessionId) {
|
|
749
|
-
throw new HTTPException(422, {
|
|
895
|
+
throw new HTTPException(422, {
|
|
896
|
+
message:
|
|
897
|
+
"sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create.",
|
|
898
|
+
});
|
|
750
899
|
}
|
|
751
900
|
const parent = await getSession(db, workspaceId, parentSessionId);
|
|
752
901
|
if (!parent) {
|
|
753
|
-
throw new HTTPException(404, {
|
|
902
|
+
throw new HTTPException(404, {
|
|
903
|
+
message: `parent session not found in workspace: ${parentSessionId}`,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
const parentBoxed = parent.sandboxBackend !== "none";
|
|
907
|
+
const variableSetMismatch =
|
|
908
|
+
parentBoxed && !variableSetMatchesGroup(parent.variableSetId ?? null);
|
|
909
|
+
let rigMismatch = parentBoxed && !rigVersionMatchesGroup(parent.rigVersionId ?? null);
|
|
910
|
+
if (parentBoxed && !rigMismatch) {
|
|
911
|
+
const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
|
|
912
|
+
db,
|
|
913
|
+
workspaceId,
|
|
914
|
+
parent.sandboxGroupId,
|
|
915
|
+
);
|
|
916
|
+
rigMismatch = !memberRigVersionIds.every((memberRigVersionId) =>
|
|
917
|
+
rigVersionMatchesGroup(memberRigVersionId),
|
|
918
|
+
);
|
|
754
919
|
}
|
|
755
|
-
if (
|
|
920
|
+
if (variableSetMismatch || rigMismatch) {
|
|
756
921
|
if (payload.sandbox === "shared") {
|
|
757
922
|
// The caller explicitly asked to share while carrying a different
|
|
758
|
-
//
|
|
759
|
-
|
|
923
|
+
// VariableSet / rig — surface the conflict at create time, not turn time.
|
|
924
|
+
// VariableSet is checked first so its (pre-rig) message is unchanged for
|
|
925
|
+
// the env-only mismatch the existing gate already covered.
|
|
926
|
+
throw new HTTPException(422, {
|
|
927
|
+
message: variableSetMismatch
|
|
928
|
+
? "sandbox:'shared' requires the same variableSet / same environment as the creator's box (the box variable set/environment is fixed at creation); omit sandbox or pass 'new' when attaching a different variableSet/environment."
|
|
929
|
+
: "sandbox:'shared' requires the same rig as the creator's box (the box's rig setup is fixed at creation); omit sandbox or pass 'new' when binding a different rig.",
|
|
930
|
+
});
|
|
760
931
|
}
|
|
761
932
|
// Inherited default: deterministic separation on the genuine shared-state
|
|
762
933
|
// conflict — the worker gets its own box (resolved like a top-level
|
|
@@ -769,17 +940,46 @@ export async function createSessionForRequest(
|
|
|
769
940
|
} else if (typeof sandboxChoice === "object") {
|
|
770
941
|
const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
|
|
771
942
|
if (!member) {
|
|
772
|
-
throw new HTTPException(404, {
|
|
943
|
+
throw new HTTPException(404, {
|
|
944
|
+
message: `sandbox group not found in workspace: ${sandboxChoice.groupId}`,
|
|
945
|
+
});
|
|
773
946
|
}
|
|
774
947
|
if (member.sandboxBackend !== "none") {
|
|
775
948
|
// Compare against EVERY member, not one arbitrary row: a legacy env-blind
|
|
776
|
-
// group can carry mixed
|
|
949
|
+
// group can carry mixed variableSetIds, and an any-member read would make
|
|
777
950
|
// the join verdict nondeterministic. Post-env-aware groups are homogeneous
|
|
778
951
|
// (both join paths enforce equality), so this reads one distinct value in
|
|
779
952
|
// the common case; a mixed legacy group deterministically rejects.
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
953
|
+
const memberVariableSetIds = await listDistinctVariableSetIdsInGroup(
|
|
954
|
+
db,
|
|
955
|
+
workspaceId,
|
|
956
|
+
sandboxChoice.groupId,
|
|
957
|
+
);
|
|
958
|
+
if (
|
|
959
|
+
!memberVariableSetIds.every((memberVariableSetId) =>
|
|
960
|
+
variableSetMatchesGroup(memberVariableSetId),
|
|
961
|
+
)
|
|
962
|
+
) {
|
|
963
|
+
throw new HTTPException(422, {
|
|
964
|
+
message: `sandbox group ${sandboxChoice.groupId} runs a different variableSet / different environment (the box variable set/environment is fixed at creation); create with the group's variableSet/environment or omit sandbox for an own box.`,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
// Same deterministic all-members check for the frozen rig version (M3): the
|
|
968
|
+
// box's rig setup is fixed at creation, so every member must ride the rig
|
|
969
|
+
// this create resolved (or the group is rig-less and so is this create).
|
|
970
|
+
const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
|
|
971
|
+
db,
|
|
972
|
+
workspaceId,
|
|
973
|
+
sandboxChoice.groupId,
|
|
974
|
+
);
|
|
975
|
+
if (
|
|
976
|
+
!memberRigVersionIds.every((memberRigVersionId) =>
|
|
977
|
+
rigVersionMatchesGroup(memberRigVersionId),
|
|
978
|
+
)
|
|
979
|
+
) {
|
|
980
|
+
throw new HTTPException(422, {
|
|
981
|
+
message: `sandbox group ${sandboxChoice.groupId} runs a different rig (the box's rig setup is fixed at creation); create with the group's rig or omit sandbox for an own box.`,
|
|
982
|
+
});
|
|
783
983
|
}
|
|
784
984
|
}
|
|
785
985
|
sandboxGroupId = sandboxChoice.groupId;
|
|
@@ -791,7 +991,10 @@ export async function createSessionForRequest(
|
|
|
791
991
|
// — reject it at the edge (mirrors the backend:'none' guard) rather than silently
|
|
792
992
|
// dropping it, since the default group box has no working-dir seam yet.
|
|
793
993
|
if (payload.workingDir !== undefined && !payload.targetSandboxId) {
|
|
794
|
-
throw new HTTPException(422, {
|
|
994
|
+
throw new HTTPException(422, {
|
|
995
|
+
message:
|
|
996
|
+
"workingDir requires targetSandboxId (it is the targeted machine's working directory)",
|
|
997
|
+
});
|
|
795
998
|
}
|
|
796
999
|
// Honest-label (Stage-D closure): a top-level session TARGETED at a Connected
|
|
797
1000
|
// Machine (a selfhosted sandbox) runs machine-primary every turn, so its HOME
|
|
@@ -821,23 +1024,32 @@ export async function createSessionForRequest(
|
|
|
821
1024
|
let machineHomeBackend: Session["sandboxBackend"] | undefined;
|
|
822
1025
|
let machineHomeOs: Session["sandboxOs"] | undefined;
|
|
823
1026
|
if (
|
|
824
|
-
payload.targetSandboxId
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1027
|
+
payload.targetSandboxId &&
|
|
1028
|
+
inheritedBackend === undefined &&
|
|
1029
|
+
settings.sandboxOwnershipEnabled &&
|
|
1030
|
+
settings.sandboxSelfhostedEnabled
|
|
828
1031
|
) {
|
|
829
1032
|
const targetSandbox = await getSandbox(db, workspaceId, payload.targetSandboxId);
|
|
830
1033
|
if (targetSandbox?.kind === "selfhosted") {
|
|
831
1034
|
machineHomeBackend = "selfhosted";
|
|
832
1035
|
if (targetSandbox.enrollmentId) {
|
|
833
1036
|
const enrollment = await getEnrollment(db, workspaceId, targetSandbox.enrollmentId);
|
|
834
|
-
if (
|
|
1037
|
+
if (
|
|
1038
|
+
enrollment &&
|
|
1039
|
+
(enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")
|
|
1040
|
+
) {
|
|
835
1041
|
machineHomeOs = enrollment.os;
|
|
836
1042
|
}
|
|
837
1043
|
}
|
|
838
1044
|
}
|
|
839
1045
|
}
|
|
840
|
-
await requireLimit(deps, {
|
|
1046
|
+
await requireLimit(deps, {
|
|
1047
|
+
accountId: grant.accountId,
|
|
1048
|
+
workspaceId,
|
|
1049
|
+
action: "agent_run:create",
|
|
1050
|
+
quantity: 1,
|
|
1051
|
+
model,
|
|
1052
|
+
});
|
|
841
1053
|
const session = await createAndStartSession({
|
|
842
1054
|
db,
|
|
843
1055
|
bus,
|
|
@@ -855,14 +1067,18 @@ export async function createSessionForRequest(
|
|
|
855
1067
|
// machine-targeted top-level create labels the home "selfhosted"
|
|
856
1068
|
// (machineHomeBackend), overriding the caller/deployment default so the row
|
|
857
1069
|
// matches where the session actually runs.
|
|
858
|
-
sandboxBackend:
|
|
1070
|
+
sandboxBackend:
|
|
1071
|
+
inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
|
|
859
1072
|
// Mirror the backend relabel on the OS axis: only a machine-targeted
|
|
860
1073
|
// top-level create carries a derived OS; everything else is omitted and the
|
|
861
1074
|
// "linux" default holds (shared spawns keep the parent-box behavior).
|
|
862
1075
|
...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
|
|
863
1076
|
sandboxGroupId,
|
|
864
1077
|
metadata: payload.metadata,
|
|
865
|
-
|
|
1078
|
+
variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
|
|
1079
|
+
// Frozen rig binding (M3): both null for a rig-less session (today's path).
|
|
1080
|
+
rigId: frozenRigId,
|
|
1081
|
+
rigVersionId: frozenRigVersionId,
|
|
866
1082
|
goal: payload.goal ?? null,
|
|
867
1083
|
// Per-session persona instructions (already trimmed/validated by the
|
|
868
1084
|
// contracts schema). Persisted on the row; composed system-level at turn
|
|
@@ -903,7 +1119,7 @@ export async function createSessionForRequest(
|
|
|
903
1119
|
* workspace's default capability MCP tools, matching an absent `tools` key.
|
|
904
1120
|
*/
|
|
905
1121
|
export async function acceptSessionUserMessage(
|
|
906
|
-
deps:
|
|
1122
|
+
deps: AcceptSessionUserMessageDependencies,
|
|
907
1123
|
grant: AccessGrant,
|
|
908
1124
|
workspaceId: string,
|
|
909
1125
|
sessionId: string,
|
|
@@ -916,15 +1132,26 @@ export async function acceptSessionUserMessage(
|
|
|
916
1132
|
reasoningEffort?: ReasoningEffort | null;
|
|
917
1133
|
clientEventId?: string;
|
|
918
1134
|
mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
|
|
1135
|
+
delivery?: "queue" | "steer";
|
|
1136
|
+
origin?: "human" | "operator";
|
|
1137
|
+
expectedControlGeneration?: number;
|
|
1138
|
+
expectedWorkspaceInferenceGeneration?: number;
|
|
919
1139
|
},
|
|
920
1140
|
): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
|
|
921
1141
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
922
|
-
const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
1142
|
+
const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
1143
|
+
db,
|
|
1144
|
+
workspaceId,
|
|
1145
|
+
settings,
|
|
1146
|
+
);
|
|
923
1147
|
// Hoisted above requireLimit so the codex-billed predicate can resolve the
|
|
924
1148
|
// turn's effective model (a follow-up turn inherits the session's model). A
|
|
925
1149
|
// pure read with no side effects.
|
|
926
1150
|
const existingSession = await requireSession(db, workspaceId, sessionId);
|
|
927
|
-
const runtimeSettings = settingsWithSessionMcpServerMetadata(
|
|
1151
|
+
const runtimeSettings = settingsWithSessionMcpServerMetadata(
|
|
1152
|
+
capabilityRuntimeSettings,
|
|
1153
|
+
existingSession.mcpServers,
|
|
1154
|
+
);
|
|
928
1155
|
const requestedResources = normalizeResources(input.resources ?? []);
|
|
929
1156
|
const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
|
|
930
1157
|
const requestedTools = input.toolsProvided
|
|
@@ -941,7 +1168,10 @@ export async function acceptSessionUserMessage(
|
|
|
941
1168
|
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
942
1169
|
}
|
|
943
1170
|
await validateFileResources(db, workspaceId, requestedResources);
|
|
944
|
-
await validateGitHubRepositorySelection(db, workspaceId, [
|
|
1171
|
+
await validateGitHubRepositorySelection(db, workspaceId, [
|
|
1172
|
+
...existingSession.resources,
|
|
1173
|
+
...requestedResources,
|
|
1174
|
+
]);
|
|
945
1175
|
const mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
|
|
946
1176
|
settings,
|
|
947
1177
|
grant,
|
|
@@ -962,6 +1192,17 @@ export async function acceptSessionUserMessage(
|
|
|
962
1192
|
model: input.model ?? null,
|
|
963
1193
|
reasoningEffort: input.reasoningEffort ?? null,
|
|
964
1194
|
mcpCredentialUpdates,
|
|
1195
|
+
delivery: input.delivery ?? "queue",
|
|
1196
|
+
origin: input.origin ?? "human",
|
|
1197
|
+
actor: grant.subjectId,
|
|
1198
|
+
...(input.expectedControlGeneration !== undefined
|
|
1199
|
+
? { expectedControlGeneration: input.expectedControlGeneration }
|
|
1200
|
+
: {}),
|
|
1201
|
+
...(input.expectedWorkspaceInferenceGeneration !== undefined
|
|
1202
|
+
? {
|
|
1203
|
+
expectedWorkspaceInferenceGeneration: input.expectedWorkspaceInferenceGeneration,
|
|
1204
|
+
}
|
|
1205
|
+
: {}),
|
|
965
1206
|
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
966
1207
|
});
|
|
967
1208
|
await recordWorkspaceUsage(deps, {
|
|
@@ -997,18 +1238,31 @@ export async function updateSessionTitle(
|
|
|
997
1238
|
const { db, bus } = deps;
|
|
998
1239
|
const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
|
|
999
1240
|
if (result.updated) {
|
|
1000
|
-
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1241
|
+
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
|
|
1242
|
+
{
|
|
1243
|
+
type: "session.title_set",
|
|
1244
|
+
payload: {
|
|
1245
|
+
title: result.title ?? title,
|
|
1246
|
+
source,
|
|
1247
|
+
},
|
|
1005
1248
|
},
|
|
1006
|
-
|
|
1249
|
+
]);
|
|
1007
1250
|
}
|
|
1008
1251
|
return result;
|
|
1009
1252
|
}
|
|
1010
1253
|
|
|
1011
|
-
function
|
|
1254
|
+
export async function readSessionLineage(db: Database, workspaceId: string, sessionId: string) {
|
|
1255
|
+
const lineage = await getSessionLineage(db, workspaceId, sessionId);
|
|
1256
|
+
if (!lineage) {
|
|
1257
|
+
throw new HTTPException(404, { message: "session not found" });
|
|
1258
|
+
}
|
|
1259
|
+
return lineage;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function withFirstPartyTools(
|
|
1263
|
+
tools: ToolRef[],
|
|
1264
|
+
runtimeSettings: { mcpServers: Array<{ id: string }> },
|
|
1265
|
+
): ToolRef[] {
|
|
1012
1266
|
if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
|
|
1013
1267
|
return tools;
|
|
1014
1268
|
}
|
|
@@ -1016,5 +1270,7 @@ function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Ar
|
|
|
1016
1270
|
}
|
|
1017
1271
|
|
|
1018
1272
|
function hasOwnProperty(value: unknown, key: string): boolean {
|
|
1019
|
-
return Boolean(
|
|
1273
|
+
return Boolean(
|
|
1274
|
+
value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
|
|
1275
|
+
);
|
|
1020
1276
|
}
|