@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.
@@ -0,0 +1,812 @@
1
+ import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
2
+ import { configuredAllowedModels, type Settings } from "@opengeni/config";
3
+ import {
4
+ CreateSessionRequest,
5
+ reasoningEffortForMetadata,
6
+ type AccessGrant,
7
+ type GoalSpec,
8
+ type Permission,
9
+ type ReasoningEffort,
10
+ type ResourceRef,
11
+ type Session,
12
+ type SessionEvent,
13
+ type SessionTurn,
14
+ type ToolRef,
15
+ } from "@opengeni/contracts";
16
+ import {
17
+ appendSessionEventsWithLockedSessionUpdate,
18
+ createSession,
19
+ createSessionGoal,
20
+ createSessionWithIdempotencyKey,
21
+ enqueueSessionTurn,
22
+ getAnySessionInGroup,
23
+ getEnrollment,
24
+ listDistinctEnvironmentIdsInGroup,
25
+ getSandbox,
26
+ getSession,
27
+ getSessionByCreateIdempotencyKey,
28
+ getSessionTurn,
29
+ requireSession,
30
+ setTemporalWorkflowId,
31
+ updateSessionTitle as updateSessionTitleRow,
32
+ type Database,
33
+ } from "@opengeni/db";
34
+ import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
35
+ import { HTTPException } from "hono/http-exception";
36
+ import { hasPermission } from "../access";
37
+ import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
38
+ import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
39
+ import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
40
+ import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
41
+ import { validateEnvironmentAttachment } from "./environments";
42
+ import {
43
+ mergeResourceRefs,
44
+ mergeToolRefs,
45
+ normalizeResources,
46
+ validateFileResources,
47
+ validateGitHubRepositorySelection,
48
+ validateToolRefs,
49
+ withDefaultEnabledCapabilityMcpTools,
50
+ } from "./resources";
51
+
52
+ export async function createAndStartSession(input: {
53
+ db: Database;
54
+ bus: EventBus;
55
+ workflowClient: SessionWorkflowClient;
56
+ accountId: string;
57
+ workspaceId: string;
58
+ initialMessage: string;
59
+ resources: ResourceRef[];
60
+ tools: ToolRef[];
61
+ clientEventId?: string;
62
+ model: string;
63
+ reasoningEffort: Settings["openaiReasoningEffort"];
64
+ sandboxBackend: Settings["sandboxBackend"];
65
+ metadata: Record<string, unknown>;
66
+ // Names/ids only; the session.created payload never carries variable values.
67
+ environment?: { id: string; name: string } | null;
68
+ goal?: GoalSpec | null;
69
+ // Validated against the creating grant before this is called.
70
+ firstPartyMcpPermissions?: Permission[] | null;
71
+ // The manager session spawning this worker (a worker-signed sessionId claim
72
+ // on the creating grant); null for direct API creates and scheduled runs.
73
+ // When set, the worker's terminal-for-now transitions wake this parent.
74
+ parentSessionId?: string | null;
75
+ // Workspace-scoped CREATE idempotency key. When present, a double-fire with
76
+ // the same key (sequential retry OR concurrent race) collapses to a single
77
+ // session: a prior winner is returned as-is and the start flow below is
78
+ // skipped, so the dup never re-emits events / re-enqueues a turn.
79
+ createIdempotencyKey?: string | null;
80
+ // The shared-sandbox group this session's box joins (addendum 05 §D). Null/
81
+ // omitted ⇒ a singleton group (the new row's own id, today's 1:1 behavior); a
82
+ // shared/{groupId} spawn passes the resolved group so both run in ONE box.
83
+ sandboxGroupId?: string | null;
84
+ // The OS axis of the session's box (sessions.sandbox_os). Omitted ⇒ the
85
+ // "linux" default; set only for a machine-targeted top-level create, where the
86
+ // targeted machine's enrollment OS is threaded in so the row + resume path +
87
+ // OS-labeling surfaces honestly reflect the machine.
88
+ sandboxOs?: Session["sandboxOs"];
89
+ // Create-time machine targeting (A-2a, RACE-FREE): the enrolled machine (a
90
+ // sandbox id) to run this session on. When set, the active-sandbox pointer is
91
+ // resolved+validated+seeded (epoch-fenced) INSIDE finishStartSession, AFTER the
92
+ // session row exists but BEFORE the first turn is enqueued/the workflow woken,
93
+ // so the FIRST turn routes to the chosen machine. An invalid/unowned/offline
94
+ // target fails the create (422) — never a silent fall-back to the default box.
95
+ // `workingDir` (optional) is the path/cwd base the chosen machine runs under,
96
+ // seeded alongside the pointer through the epoch-fenced CAS.
97
+ seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
98
+ }) {
99
+ const sessionMetadata = {
100
+ ...input.metadata,
101
+ model: input.model,
102
+ reasoningEffort: input.reasoningEffort,
103
+ };
104
+ // Fast path with a key: return a session already created under this key
105
+ // (the sequential retry / double-submit case) without inserting again.
106
+ if (input.createIdempotencyKey) {
107
+ const existing = await getSessionByCreateIdempotencyKey(input.db, input.workspaceId, input.createIdempotencyKey);
108
+ if (existing) {
109
+ return existing;
110
+ }
111
+ // No prior session: insert under the key, racing concurrent creates. The
112
+ // partial unique index lets exactly one insert win; a loser gets back the
113
+ // winner's row with created=false and must NOT run the start flow (the
114
+ // winner owns the events/turn/workflow), so we return it as-is.
115
+ const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
116
+ accountId: input.accountId,
117
+ workspaceId: input.workspaceId,
118
+ initialMessage: input.initialMessage,
119
+ resources: input.resources,
120
+ tools: input.tools,
121
+ metadata: sessionMetadata,
122
+ model: input.model,
123
+ sandboxBackend: input.sandboxBackend,
124
+ environmentId: input.environment?.id ?? null,
125
+ firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
126
+ parentSessionId: input.parentSessionId ?? null,
127
+ createIdempotencyKey: input.createIdempotencyKey,
128
+ sandboxGroupId: input.sandboxGroupId ?? null,
129
+ ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
130
+ });
131
+ if (!created) {
132
+ return keyed;
133
+ }
134
+ return await finishStartSession(input, keyed);
135
+ }
136
+ const session = await createSession(input.db, {
137
+ accountId: input.accountId,
138
+ workspaceId: input.workspaceId,
139
+ initialMessage: input.initialMessage,
140
+ resources: input.resources,
141
+ tools: input.tools,
142
+ metadata: sessionMetadata,
143
+ model: input.model,
144
+ sandboxBackend: input.sandboxBackend,
145
+ environmentId: input.environment?.id ?? null,
146
+ firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
147
+ parentSessionId: input.parentSessionId ?? null,
148
+ sandboxGroupId: input.sandboxGroupId ?? null,
149
+ ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
150
+ });
151
+ return await finishStartSession(input, session);
152
+ }
153
+
154
+ /**
155
+ * The post-insert half of {@link createAndStartSession}: durable goal row,
156
+ * the initial event batch (session.created / goal.set / user.message /
157
+ * status.changed), turn enqueue, and the workflow wake. Split out so the
158
+ * idempotency-key winner and the key-less create share one body, and the
159
+ * idempotency-key loser/dup can skip it entirely.
160
+ */
161
+ async function finishStartSession(input: {
162
+ db: Database;
163
+ bus: EventBus;
164
+ workflowClient: SessionWorkflowClient;
165
+ initialMessage: string;
166
+ resources: ResourceRef[];
167
+ tools: ToolRef[];
168
+ clientEventId?: string;
169
+ model: string;
170
+ reasoningEffort: Settings["openaiReasoningEffort"];
171
+ sandboxBackend: Settings["sandboxBackend"];
172
+ environment?: { id: string; name: string } | null;
173
+ goal?: GoalSpec | null;
174
+ seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
175
+ }, session: Session): Promise<Session> {
176
+ // The goal row is durable session state; the workflow picks it up from the
177
+ // database once the first turn completes — no extra workflow plumbing here.
178
+ const goal = input.goal
179
+ ? await createSessionGoal(input.db, {
180
+ accountId: session.accountId,
181
+ workspaceId: session.workspaceId,
182
+ sessionId: session.id,
183
+ text: input.goal.text,
184
+ successCriteria: input.goal.successCriteria ?? null,
185
+ maxAutoContinuations: input.goal.maxAutoContinuations ?? null,
186
+ createdBy: "api",
187
+ })
188
+ : null;
189
+ const initialPayload = {
190
+ text: input.initialMessage,
191
+ ...(input.resources.length ? { resources: input.resources } : {}),
192
+ ...(input.tools.length ? { tools: input.tools } : {}),
193
+ };
194
+ const events = await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [
195
+ {
196
+ type: "session.created",
197
+ payload: {
198
+ status: "queued",
199
+ ...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
200
+ },
201
+ },
202
+ ...(goal ? [{
203
+ type: "goal.set" as const,
204
+ payload: {
205
+ goalId: goal.id,
206
+ text: goal.text,
207
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
208
+ version: goal.version,
209
+ actor: "api",
210
+ replaced: false,
211
+ },
212
+ }] : []),
213
+ {
214
+ type: "user.message",
215
+ payload: initialPayload,
216
+ ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
217
+ },
218
+ { type: "session.status.changed", payload: { status: "queued" } },
219
+ ]);
220
+ const userEvent = events.find((event) => event.type === "user.message");
221
+ if (!userEvent) {
222
+ throw new HTTPException(500, { message: "failed to append initial user event" });
223
+ }
224
+ // Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
225
+ // the first turn is enqueued + the workflow woken, so the FIRST turn routes to
226
+ // the chosen machine. Race-free: the epoch-fenced setActiveSandbox commits here,
227
+ // before wakeSessionWorkflow below signals the worker. swapActiveSandbox does
228
+ // the same ownership+liveness validation as the live swap; an invalid/unowned/
229
+ // offline target FAILS the create (422) — never a silent fall-back to the box.
230
+ if (input.seedTargetSandbox) {
231
+ if (session.sandboxBackend === "none") {
232
+ throw new HTTPException(422, {
233
+ message: "cannot target a machine for a session with no sandbox (backend: none)",
234
+ });
235
+ }
236
+ const ctx: FleetContext = {
237
+ accountId: session.accountId,
238
+ workspaceId: session.workspaceId,
239
+ sessionId: session.id,
240
+ sessionBackend: session.sandboxBackend,
241
+ sessionGroupId: session.sandboxGroupId,
242
+ };
243
+ const seeded = await swapActiveSandbox(
244
+ { db: input.db, settings: input.seedTargetSandbox.settings, bus: input.bus },
245
+ ctx,
246
+ input.seedTargetSandbox.sandboxId,
247
+ // The working dir is committed in the SAME epoch-fenced CAS that seeds the
248
+ // pointer, so the first turn routes to the machine AND lands in working_dir.
249
+ input.seedTargetSandbox.workingDir ?? null,
250
+ );
251
+ if (!seeded.swapped) {
252
+ throw new HTTPException(422, {
253
+ message: `cannot target sandbox ${input.seedTargetSandbox.sandboxId}: ${seeded.reason ?? "target is not attachable"}`,
254
+ });
255
+ }
256
+ }
257
+ const workflowId = workflowIdForSession(session.id);
258
+ await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
259
+ const turn = await enqueueSessionTurn(input.db, {
260
+ accountId: session.accountId,
261
+ workspaceId: session.workspaceId,
262
+ sessionId: session.id,
263
+ triggerEventId: userEvent.id,
264
+ temporalWorkflowId: workflowId,
265
+ source: "user",
266
+ prompt: input.initialMessage,
267
+ resources: input.resources,
268
+ tools: input.tools,
269
+ model: input.model,
270
+ reasoningEffort: input.reasoningEffort,
271
+ sandboxBackend: input.sandboxBackend,
272
+ metadata: {},
273
+ });
274
+ await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [{
275
+ type: "turn.queued",
276
+ turnId: turn.id,
277
+ payload: { turnId: turn.id, triggerEventId: userEvent.id, source: turn.source },
278
+ }]);
279
+ await input.workflowClient.wakeSessionWorkflow({ accountId: session.accountId, workspaceId: session.workspaceId, sessionId: session.id, workflowId });
280
+ return await requireSession(input.db, session.workspaceId, session.id);
281
+ }
282
+
283
+ export function workflowIdForSession(sessionId: string): string {
284
+ return `session-${sessionId}`;
285
+ }
286
+
287
+ /**
288
+ * Reject an explicit model that the host does not expose. The set of usable
289
+ * models is the union surfaced by `configuredAllowedModels` (the built-in
290
+ * provider's allow-list plus every registry provider's ids); a `model` outside
291
+ * it cannot be resolved to a provider at run time, so we fail the request at
292
+ * the API edge with 422 rather than enqueuing a turn the worker can't honor.
293
+ *
294
+ * `model` is the explicit, caller-supplied value (null/undefined when omitted).
295
+ * An omitted model defaults to `settings.openaiModel` downstream — which is
296
+ * always first in `configuredAllowedModels` — so only an explicit value is
297
+ * checked. Centralized here so every model-carrying choke point
298
+ * (create-session, user-message/turn-accept, queued-turn update, and
299
+ * scheduled-task agentConfig — a scheduled task is a session the worker runs
300
+ * later) and the MCP surfaces that share them validate identically and cannot
301
+ * drift.
302
+ */
303
+ export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
304
+ if (model === null || model === undefined) {
305
+ return;
306
+ }
307
+ if (configuredAllowedModels(settings).includes(model)) {
308
+ return;
309
+ }
310
+ // Codex subscription models (codex/<slug>) are injected per-workspace by the
311
+ // worker overlay at turn time, so they are never in the deployment-global
312
+ // allow-list. Accept them at the edge when the feature is enabled — the picker
313
+ // only surfaces them for a connected workspace, and the worker enforces the
314
+ // actual connection (an unconnected workspace fails the turn with a clear
315
+ // "no Codex subscription connected" error rather than a misleading 422 here).
316
+ if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
317
+ return;
318
+ }
319
+ throw new HTTPException(422, { message: `model is not available: ${model}` });
320
+ }
321
+
322
+ export async function requireQueuedTurnForApi(db: Database, workspaceId: string, sessionId: string, turnId: string): Promise<SessionTurn> {
323
+ const turn = await getSessionTurn(db, workspaceId, turnId);
324
+ if (!turn || turn.sessionId !== sessionId) {
325
+ throw new HTTPException(404, { message: "session turn not found" });
326
+ }
327
+ if (turn.status !== "queued") {
328
+ throw new HTTPException(409, { message: `turn is ${turn.status}; only queued turns can be changed` });
329
+ }
330
+ return turn;
331
+ }
332
+
333
+ export function reasoningEffortForSession(metadata: Record<string, unknown>, fallback: Settings["openaiReasoningEffort"]): Settings["openaiReasoningEffort"] {
334
+ return reasoningEffortForMetadata(metadata, fallback);
335
+ }
336
+
337
+ /**
338
+ * Appends a `user.message` to an existing session and enqueues the resulting
339
+ * turn, merging requested resources/tools into the session and waking the
340
+ * workflow. Shared by the public events route and the first-party MCP
341
+ * `session_send_message` tool so the two surfaces cannot drift. Callers own
342
+ * resource/tool validation and the per-message usage limit before calling.
343
+ */
344
+ export async function postUserMessageTurn(input: {
345
+ db: Database;
346
+ bus: EventBus;
347
+ workflowClient: SessionWorkflowClient;
348
+ settings: Settings;
349
+ accountId: string;
350
+ workspaceId: string;
351
+ sessionId: string;
352
+ text: string;
353
+ resources: ResourceRef[];
354
+ tools: ToolRef[];
355
+ model?: string | null;
356
+ reasoningEffort?: Settings["openaiReasoningEffort"] | null;
357
+ clientEventId?: string;
358
+ }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
359
+ const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
360
+ const requestedModel = input.model ?? null;
361
+ const requestedReasoningEffort = input.reasoningEffort ?? null;
362
+ // Reject an explicit per-message model the host does not expose; an omitted
363
+ // model inherits the session's model downstream (always a configured id).
364
+ assertConfiguredModel(settings, requestedModel);
365
+ const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, (lockedSession) => {
366
+ // Cancelled is the one terminal state: an explicit user act. A FAILED
367
+ // session stays revivable by talking to it — conversation truth lives in
368
+ // session_history_items, so a failed turn does not invalidate history,
369
+ // and the manager channel of record must always answer when spoken to.
370
+ // The new message transitions failed -> queued (clearing the stale
371
+ // activeTurnId) and the signalWithStart below starts a fresh workflow
372
+ // run for the completed (failed) one, exactly as for idle sessions.
373
+ if (lockedSession.status === "cancelled") {
374
+ throw new HTTPException(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
375
+ }
376
+ const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
377
+ const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
378
+ const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
379
+ return {
380
+ events: [
381
+ {
382
+ type: "user.message",
383
+ payload: {
384
+ text: input.text,
385
+ ...(input.resources.length ? { resources: input.resources } : {}),
386
+ ...(input.tools.length ? { tools: input.tools } : {}),
387
+ ...(requestedModel ? { model: requestedModel } : {}),
388
+ ...(requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}),
389
+ },
390
+ ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
391
+ },
392
+ ...(shouldQueueSession ? [{ type: "session.status.changed" as const, payload: { status: "queued" } }] : []),
393
+ ],
394
+ update: {
395
+ resources: nextResources,
396
+ tools: nextTools,
397
+ ...(shouldQueueSession ? { status: "queued" as const, activeTurnId: null } : {}),
398
+ },
399
+ };
400
+ }).then(async (events) => {
401
+ await bus.publish(workspaceId, sessionId, events);
402
+ return events;
403
+ });
404
+ const accepted = appended[0];
405
+ if (!accepted) {
406
+ throw new HTTPException(500, { message: "failed to append client event" });
407
+ }
408
+ const workflowId = workflowIdForSession(sessionId);
409
+ const session = await requireSession(db, workspaceId, sessionId);
410
+ const turn = await enqueueSessionTurn(db, {
411
+ accountId,
412
+ workspaceId,
413
+ sessionId,
414
+ triggerEventId: accepted.id,
415
+ temporalWorkflowId: workflowId,
416
+ source: "user",
417
+ prompt: input.text,
418
+ resources: input.resources,
419
+ tools: input.tools,
420
+ model: requestedModel ?? session.model,
421
+ reasoningEffort: requestedReasoningEffort ?? reasoningEffortForSession(session.metadata, settings.openaiReasoningEffort),
422
+ sandboxBackend: session.sandboxBackend,
423
+ metadata: {},
424
+ });
425
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
426
+ type: "turn.queued",
427
+ turnId: turn.id,
428
+ payload: { turnId: turn.id, triggerEventId: accepted.id, source: turn.source },
429
+ }]);
430
+ await workflowClient.wakeSessionWorkflow({ accountId, workspaceId, sessionId, workflowId });
431
+ return { accepted, turn };
432
+ }
433
+
434
+ /**
435
+ * Full create-session flow shared by `POST /sessions` and the first-party MCP
436
+ * `session_create` tool: payload validation, resource/tool/environment
437
+ * checks, usage limits, session start, and usage recording. `rawPayload` is
438
+ * the unparsed request body so absent-vs-empty `tools` keeps its meaning
439
+ * (absent applies the workspace's default capability MCP tools).
440
+ */
441
+ export async function createSessionForRequest(
442
+ deps: ApiRouteDeps,
443
+ grant: AccessGrant,
444
+ workspaceId: string,
445
+ rawPayload: unknown,
446
+ ): Promise<Session> {
447
+ const { settings, db, bus, workflowClient, objectStorage } = deps;
448
+ const payload = CreateSessionRequest.parse(rawPayload);
449
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
450
+ const resources = normalizeResources(payload.resources);
451
+ const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
452
+ const defaultedTools = hasOwnProperty(rawPayload, "tools")
453
+ ? requestedTools
454
+ : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, runtimeSettings);
455
+ // The first-party MCP server is attached to EVERY session. It hosts the
456
+ // session's own metadata tool (set_session_title) + goal tools, and — only
457
+ // when the grant carries the permission — the orchestration/environment/
458
+ // github tools. Capability is gated per-tool by permission, never by whether
459
+ // the server is attached, so a bare chat still gets titling while the
460
+ // dangerous tools stay off by default.
461
+ const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
462
+ await validateGitHubRepositorySelection(db, workspaceId, resources);
463
+ if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
464
+ throw new HTTPException(503, { message: "object storage is not configured" });
465
+ }
466
+ await validateFileResources(db, workspaceId, resources);
467
+ // Environment attachment requires environments:use on the calling grant
468
+ // (validateEnvironmentAttachment enforces it), preserving the invariant
469
+ // that sandboxed agents cannot self-attach workspace secrets.
470
+ const environment = payload.environmentId
471
+ ? await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, payload.environmentId)
472
+ : null;
473
+ assertConfiguredModel(settings, payload.model);
474
+ const model = payload.model ?? settings.openaiModel;
475
+ const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
476
+ // A session's first-party MCP token can carry a non-default permission set
477
+ // (how an operator hands a manager-style session the orchestration tools),
478
+ // but never one out-ranking its creator: every requested permission must be
479
+ // held by the creating grant.
480
+ let firstPartyMcpPermissions = payload.firstPartyMcpPermissions ?? null;
481
+ if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
482
+ // An empty set would sign an unusable zero-permission token; the default
483
+ // worker set is expressed by omitting the field.
484
+ throw new HTTPException(422, { message: "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set" });
485
+ }
486
+ for (const permission of firstPartyMcpPermissions ?? []) {
487
+ if (!hasPermission(grant.permissions, permission)) {
488
+ throw new HTTPException(403, { message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}` });
489
+ }
490
+ }
491
+ // Invariant: a goal-bearing session always carries goals:manage in its
492
+ // effective first-party permissions. Without it the worker's delegated
493
+ // token never sees the goal tools (goal_complete/goal_pause/...), so the
494
+ // agent cannot stop its own goal and the continuation loop runs until an
495
+ // operator intervenes. The auto-added permission is deliberately exempt
496
+ // from the creating-grant check above: goal tools are scoped to the
497
+ // spawned session itself via the worker-signed sessionId claim, so a
498
+ // worker managing its OWN goal is not an escalation of the spawner's
499
+ // authority.
500
+ if (payload.goal && firstPartyMcpPermissions && !firstPartyMcpPermissions.includes("goals:manage")) {
501
+ firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
502
+ }
503
+ // Parent linkage: a worker is linked to its manager ONLY from the
504
+ // worker-signed sessionId claim on the creating grant — the manager
505
+ // session's own id, signed into the delegated token by the worker and never
506
+ // agent- or caller-controlled. A grant without that claim (a workspace API
507
+ // key, any non-delegated grant) creates a parentless top-level session.
508
+ //
509
+ // We deliberately do NOT honor a caller-supplied parentSessionId: it would
510
+ // let any sessions:create grant aim a worker at an arbitrary session's id so
511
+ // its completion wake injects a user.message + queued turn into that session
512
+ // without holding sessions:control on it (a cross-session write escalation).
513
+ // The claim is the only trustworthy parent source.
514
+ const parentSessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
515
+ // Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
516
+ //
517
+ // The DEFAULT rule is context-dependent and resolved server-side from the
518
+ // TRUSTED claim, never caller-supplied: when `sandbox` is omitted, a session
519
+ // spawned FROM INSIDE a session (parentSessionId present ⇒ a worker-signed
520
+ // sessionId claim) defaults to "shared" (join the creator's box); a top-level
521
+ // create (no parent) defaults to "new" (a private singleton box). Explicit
522
+ // values always win.
523
+ //
524
+ // null sandboxGroupId ⇒ createSession seeds the new row's own id (singleton,
525
+ // today's 1:1 behavior). A shared/{groupId} spawn inherits the box's backend
526
+ // (it is literally the same box; the child cannot pick its own). Cross-
527
+ // workspace sharing is forbidden by construction: getSession/
528
+ // getAnySessionInGroup are RLS-workspace-scoped, so a foreign parent/group
529
+ // returns null → 404; the group uuid is NOT an access boundary, the workspace
530
+ // filter is (stress (e)).
531
+ const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
532
+ let sandboxGroupId: string | null = null;
533
+ let inheritedBackend: Session["sandboxBackend"] | undefined;
534
+ // ENV-AWARE GROUPING: under the CURRENT mechanics the workspace Environment is
535
+ // creation-time box state — the box's manifest env is fixed when it is cold-
536
+ // created, and the SDK's provided-session guard rejects any manifest-env delta
537
+ // at attach. A session carrying a DIFFERENT Environment than the box it joins
538
+ // is therefore a genuine shared-state conflict TODAY: its first turn on a warm
539
+ // box dies with "Live sandbox sessions cannot change manifest environment
540
+ // variables" (proven live, sessions 5aee77e9 + 63d18823). Until the Environment
541
+ // is evicted from the manifest (per-exec, like the git token), grouping must be
542
+ // env-aware: the INHERITED default falls back to an own box on mismatch (a
543
+ // credentialed worker spawned from a credential-less manager just works), and
544
+ // an EXPLICIT shared/{groupId} request with a mismatched Environment fails
545
+ // fast at create (422) instead of poisoning the session's first turn.
546
+ // The env conflict is a BOX property, so a boxless group is exempt: a
547
+ // backend:"none" session runs in-process with no sandbox, no manifest, and no
548
+ // provided-session attach — no shared box state exists to conflict, and
549
+ // env-differing spawns from such parents shared safely before the env-aware
550
+ // check. They keep sharing (and keep inheriting "none").
551
+ const requestedEnvironmentId = payload.environmentId ?? null;
552
+ const environmentMatchesGroup = (memberEnvironmentId: string | null): boolean =>
553
+ memberEnvironmentId === requestedEnvironmentId;
554
+ if (sandboxChoice === "shared") {
555
+ if (!parentSessionId) {
556
+ throw new HTTPException(422, { message: "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create." });
557
+ }
558
+ const parent = await getSession(db, workspaceId, parentSessionId);
559
+ if (!parent) {
560
+ throw new HTTPException(404, { message: `parent session not found in workspace: ${parentSessionId}` });
561
+ }
562
+ if (parent.sandboxBackend !== "none" && !environmentMatchesGroup(parent.environmentId ?? null)) {
563
+ if (payload.sandbox === "shared") {
564
+ // The caller explicitly asked to share while carrying a different
565
+ // Environment — surface the conflict at create time, not turn time.
566
+ throw new HTTPException(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." });
567
+ }
568
+ // Inherited default: deterministic separation on the genuine shared-state
569
+ // conflict — the worker gets its own box (resolved like a top-level
570
+ // create: payload.sandboxBackend, else the deployment default) and its
571
+ // turn runs.
572
+ } else {
573
+ sandboxGroupId = parent.sandboxGroupId;
574
+ inheritedBackend = parent.sandboxBackend;
575
+ }
576
+ } else if (typeof sandboxChoice === "object") {
577
+ const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
578
+ if (!member) {
579
+ throw new HTTPException(404, { message: `sandbox group not found in workspace: ${sandboxChoice.groupId}` });
580
+ }
581
+ if (member.sandboxBackend !== "none") {
582
+ // Compare against EVERY member, not one arbitrary row: a legacy env-blind
583
+ // group can carry mixed environmentIds, and an any-member read would make
584
+ // the join verdict nondeterministic. Post-env-aware groups are homogeneous
585
+ // (both join paths enforce equality), so this reads one distinct value in
586
+ // the common case; a mixed legacy group deterministically rejects.
587
+ const memberEnvironmentIds = await listDistinctEnvironmentIdsInGroup(db, workspaceId, sandboxChoice.groupId);
588
+ if (!memberEnvironmentIds.every((memberEnvironmentId) => environmentMatchesGroup(memberEnvironmentId))) {
589
+ throw new HTTPException(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.` });
590
+ }
591
+ }
592
+ sandboxGroupId = sandboxChoice.groupId;
593
+ inheritedBackend = member.sandboxBackend;
594
+ }
595
+ // else "new": leave sandboxGroupId null → own singleton group (group ≡ id).
596
+ // A working dir is only meaningful for a TARGETED machine (it is the chosen
597
+ // box's path/cwd base). Present without a targetSandboxId is a malformed request
598
+ // — reject it at the edge (mirrors the backend:'none' guard) rather than silently
599
+ // dropping it, since the default group box has no working-dir seam yet.
600
+ if (payload.workingDir !== undefined && !payload.targetSandboxId) {
601
+ throw new HTTPException(422, { message: "workingDir requires targetSandboxId (it is the targeted machine's working directory)" });
602
+ }
603
+ // Honest-label (Stage-D closure): a top-level session TARGETED at a Connected
604
+ // Machine (a selfhosted sandbox) runs machine-primary every turn, so its HOME
605
+ // sandbox_backend must read "selfhosted" — not the deployment cloud default —
606
+ // so the session row + first turn honestly reflect where the agent runs (the
607
+ // Machines dashboard, the turn's warm-metering, and the file-download plane all
608
+ // key off this). GUARDS: (1) only at a TOP-LEVEL create (inheritedBackend
609
+ // undefined) — a shared/{groupId} spawn is literally the creator's box and must
610
+ // NOT be relabeled; (2) only when the target's kind is actually "selfhosted" —
611
+ // targetSandboxId also accepts a first-class MODAL sandbox id (resolveTarget),
612
+ // which must never be mislabeled. A not-found / non-selfhosted / modal target
613
+ // falls through to the default; the seed swap in createAndStartSession still
614
+ // validates ownership/liveness and 422s a bad target. (3) only when the feature
615
+ // flags that make the worker actually take the machine-primary path are ON
616
+ // (sandboxOwnershipEnabled + sandboxSelfhostedEnabled/routing) — otherwise the
617
+ // worker ignores the active pointer and a home="selfhosted" turn would fall to
618
+ // the registry client with no bound agentId and throw; with the flags off we
619
+ // keep the cloud default and the machine layers as a (pre-honest-label) overlay.
620
+ // sandbox_os (the OS axis the worker's group-box resume + the OS-labeling
621
+ // surfaces key off) must ALSO reflect the targeted machine, not the "linux"
622
+ // schema default — a session run on a macOS Connected Machine that labels
623
+ // itself linux lies to those surfaces. Derived under the SAME guards as the
624
+ // backend relabel; the enrollment (joined via the sandbox's enrollmentId)
625
+ // carries the OS. enrollmentOsValues and the sessions.sandbox_os value set are
626
+ // both ("linux","macos","windows"), so a known value maps 1:1; any other value
627
+ // is left to the "linux" default (never write a value no reader understands).
628
+ let machineHomeBackend: Session["sandboxBackend"] | undefined;
629
+ let machineHomeOs: Session["sandboxOs"] | undefined;
630
+ if (
631
+ payload.targetSandboxId
632
+ && inheritedBackend === undefined
633
+ && settings.sandboxOwnershipEnabled
634
+ && settings.sandboxSelfhostedEnabled
635
+ ) {
636
+ const targetSandbox = await getSandbox(db, workspaceId, payload.targetSandboxId);
637
+ if (targetSandbox?.kind === "selfhosted") {
638
+ machineHomeBackend = "selfhosted";
639
+ if (targetSandbox.enrollmentId) {
640
+ const enrollment = await getEnrollment(db, workspaceId, targetSandbox.enrollmentId);
641
+ if (enrollment && (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")) {
642
+ machineHomeOs = enrollment.os;
643
+ }
644
+ }
645
+ }
646
+ }
647
+ await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model });
648
+ const session = await createAndStartSession({
649
+ db,
650
+ bus,
651
+ workflowClient,
652
+ accountId: grant.accountId,
653
+ workspaceId,
654
+ initialMessage: payload.initialMessage,
655
+ resources,
656
+ tools,
657
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
658
+ model,
659
+ reasoningEffort,
660
+ // A shared spawn inherits the box's backend; a caller-supplied
661
+ // sandboxBackend on a shared spawn is ignored (it is the same box). A
662
+ // machine-targeted top-level create labels the home "selfhosted"
663
+ // (machineHomeBackend), overriding the caller/deployment default so the row
664
+ // matches where the session actually runs.
665
+ sandboxBackend: inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
666
+ // Mirror the backend relabel on the OS axis: only a machine-targeted
667
+ // top-level create carries a derived OS; everything else is omitted and the
668
+ // "linux" default holds (shared spawns keep the parent-box behavior).
669
+ ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
670
+ sandboxGroupId,
671
+ metadata: payload.metadata,
672
+ environment: environment ? { id: environment.id, name: environment.name } : null,
673
+ goal: payload.goal ?? null,
674
+ firstPartyMcpPermissions,
675
+ parentSessionId,
676
+ createIdempotencyKey: payload.idempotencyKey ?? null,
677
+ // Create-time machine targeting (A-2a): when a target sandbox is named, the
678
+ // active-sandbox pointer is seeded race-free inside createAndStartSession
679
+ // (after the row exists, before the first turn dispatches). Validation
680
+ // (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
681
+ seedTargetSandbox: payload.targetSandboxId
682
+ ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
683
+ : null,
684
+ });
685
+ await recordWorkspaceUsage(deps, {
686
+ accountId: grant.accountId,
687
+ workspaceId,
688
+ subjectId: grant.subjectId,
689
+ eventType: "agent_run.created",
690
+ quantity: 1,
691
+ unit: "run",
692
+ sourceResourceType: "session",
693
+ sourceResourceId: session.id,
694
+ idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`,
695
+ });
696
+ return session;
697
+ }
698
+
699
+ /**
700
+ * Full accept-user-message flow shared by the `user.message` branch of
701
+ * `POST /sessions/:id/events` and the first-party MCP `session_send_message`
702
+ * tool: resource/tool validation, usage limits, the locked append + turn
703
+ * enqueue, and usage recording. `toolsProvided: false` applies the
704
+ * workspace's default capability MCP tools, matching an absent `tools` key.
705
+ */
706
+ export async function acceptSessionUserMessage(
707
+ deps: ApiRouteDeps,
708
+ grant: AccessGrant,
709
+ workspaceId: string,
710
+ sessionId: string,
711
+ input: {
712
+ text: string;
713
+ resources?: ResourceRef[];
714
+ tools?: ToolRef[];
715
+ toolsProvided: boolean;
716
+ model?: string | null;
717
+ reasoningEffort?: ReasoningEffort | null;
718
+ clientEventId?: string;
719
+ },
720
+ ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
721
+ const { settings, db, bus, workflowClient, objectStorage } = deps;
722
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
723
+ const requestedResources = normalizeResources(input.resources ?? []);
724
+ const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
725
+ const requestedTools = input.toolsProvided
726
+ ? validatedTools
727
+ : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, runtimeSettings);
728
+ // Hoisted above requireLimit so the codex-billed predicate can resolve the
729
+ // turn's effective model (a follow-up turn inherits the session's model). A
730
+ // pure read with no side effects.
731
+ const existingSession = await requireSession(db, workspaceId, sessionId);
732
+ await requireLimit(deps, {
733
+ accountId: grant.accountId,
734
+ workspaceId,
735
+ action: "agent_run:create",
736
+ quantity: 1,
737
+ model: input.model ?? existingSession.model,
738
+ });
739
+ if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
740
+ throw new HTTPException(503, { message: "object storage is not configured" });
741
+ }
742
+ await validateFileResources(db, workspaceId, requestedResources);
743
+ await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
744
+ const { accepted, turn } = await postUserMessageTurn({
745
+ db,
746
+ bus,
747
+ workflowClient,
748
+ settings,
749
+ accountId: grant.accountId,
750
+ workspaceId,
751
+ sessionId,
752
+ text: input.text,
753
+ resources: requestedResources,
754
+ tools: requestedTools,
755
+ model: input.model ?? null,
756
+ reasoningEffort: input.reasoningEffort ?? null,
757
+ ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
758
+ });
759
+ await recordWorkspaceUsage(deps, {
760
+ accountId: grant.accountId,
761
+ workspaceId,
762
+ subjectId: grant.subjectId,
763
+ eventType: "agent_run.created",
764
+ quantity: 1,
765
+ unit: "run",
766
+ sourceResourceType: "session_turn",
767
+ sourceResourceId: turn.id,
768
+ idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`,
769
+ });
770
+ return { accepted, turn };
771
+ }
772
+
773
+ /**
774
+ * Shared title-write path for the manual rename route AND both MCP tools
775
+ * (set_session_title / set_other_session_title). The clobber guard lives in
776
+ * the db `updateSessionTitle` UPDATE: an agent write is skipped when a user
777
+ * title already pinned the session. On a real write we emit `session.title_set`
778
+ * exactly like goal mutations emit their events; when nothing changed (agent
779
+ * write blocked by the user lock) we emit nothing. Returns whether a write
780
+ * happened so callers can avoid double work.
781
+ */
782
+ export async function updateSessionTitle(
783
+ deps: { db: Database; bus: EventBus },
784
+ workspaceId: string,
785
+ sessionId: string,
786
+ title: string,
787
+ source: "user" | "agent",
788
+ ): Promise<{ updated: boolean; title: string | null }> {
789
+ const { db, bus } = deps;
790
+ const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
791
+ if (result.updated) {
792
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
793
+ type: "session.title_set",
794
+ payload: {
795
+ title: result.title ?? title,
796
+ source,
797
+ },
798
+ }]);
799
+ }
800
+ return result;
801
+ }
802
+
803
+ function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Array<{ id: string }> }): ToolRef[] {
804
+ if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
805
+ return tools;
806
+ }
807
+ return mergeToolRefs(tools, [{ kind: "mcp", id: "opengeni" }]);
808
+ }
809
+
810
+ function hasOwnProperty(value: unknown, key: string): boolean {
811
+ return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
812
+ }