@opengeni/core 0.4.10 → 0.8.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.
@@ -2,9 +2,12 @@ import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
2
2
  import { configuredAllowedModels, policyProviderIdForModel, type Settings } from "@opengeni/config";
3
3
  import {
4
4
  CreateSessionRequest,
5
+ ServiceTurnInitiator,
6
+ ServiceTurnInitiatorContext,
5
7
  evaluateWorkspaceModelPolicy,
6
8
  reasoningEffortForMetadata,
7
9
  type AccessGrant,
10
+ type CreateSessionResponse,
8
11
  type GoalSpec,
9
12
  type Permission,
10
13
  type ReasoningEffort,
@@ -14,8 +17,11 @@ import {
14
17
  type SessionMcpCredentialUpdateInput,
15
18
  type SessionMcpServerInput,
16
19
  type SessionMcpServerMetadata,
20
+ type SessionAuthorizationPort,
17
21
  type SessionTurn,
18
22
  type ToolRef,
23
+ type TurnInitiator,
24
+ type TurnInitiatorContext,
19
25
  } from "@opengeni/contracts";
20
26
  import {
21
27
  createSession,
@@ -29,6 +35,7 @@ import {
29
35
  listDistinctRigVersionIdsInGroup,
30
36
  getSandbox,
31
37
  getSession,
38
+ SessionIdConflictError,
32
39
  getSessionByCreateIdempotencyKey,
33
40
  getSessionEvent,
34
41
  getWorkspaceControlEvent,
@@ -36,6 +43,8 @@ import {
36
43
  getSessionTurn,
37
44
  getWorkspaceModelPolicy,
38
45
  initializeSessionStartAtomically,
46
+ listSessionTurns,
47
+ listSessionMcpServersForChildInheritance,
39
48
  requireSession,
40
49
  submitHumanPromptInTransaction,
41
50
  updateSessionTitle as updateSessionTitleRow,
@@ -44,7 +53,9 @@ import {
44
53
  type Database,
45
54
  type UpdateSessionMcpServerCredentialsInput,
46
55
  QueueCommandConflictError,
56
+ AgentCommandAuthorityError,
47
57
  SessionControlConflictError,
58
+ type SessionCommandActor,
48
59
  } from "@opengeni/db";
49
60
  import {
50
61
  appendAndPublishEvents,
@@ -60,6 +71,7 @@ import type {
60
71
  ApiRouteDeps,
61
72
  SessionWorkflowClient,
62
73
  } from "../dependencies";
74
+ import { requireSessionAuthorization } from "../session-authorization";
63
75
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
64
76
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
65
77
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
@@ -84,6 +96,99 @@ type ValidatedSessionMcpServers = {
84
96
  metadata: SessionMcpServerMetadata[];
85
97
  };
86
98
 
99
+ type FrozenCreationInitiator = {
100
+ initiator?: TurnInitiator;
101
+ context?: TurnInitiatorContext;
102
+ actor?: Extract<SessionCommandActor, { type: "agent_attempt" }>;
103
+ };
104
+
105
+ function serviceInitiatorForGrant(grant: AccessGrant): {
106
+ initiator: ServiceTurnInitiator;
107
+ context: ServiceTurnInitiatorContext;
108
+ } | null {
109
+ if (!grant.serviceInitiator) {
110
+ if (grant.serviceInitiatorContext) {
111
+ throw new HTTPException(403, {
112
+ message: "service initiator context requires a signed service initiator",
113
+ });
114
+ }
115
+ return null;
116
+ }
117
+ const initiator = ServiceTurnInitiator.safeParse(grant.serviceInitiator);
118
+ if (!initiator.success) {
119
+ throw new HTTPException(403, {
120
+ message: "a delegated command initiator must be a bounded service principal",
121
+ });
122
+ }
123
+ const context = ServiceTurnInitiatorContext.safeParse(grant.serviceInitiatorContext ?? {});
124
+ if (!context.success) {
125
+ throw new HTTPException(403, {
126
+ message: "delegated service initiator context is invalid or reserved",
127
+ });
128
+ }
129
+ const callerTurnId = grant.metadata?.["turnId"];
130
+ const callerAttemptId = grant.metadata?.["attemptId"];
131
+ const callerExecutionGeneration = grant.metadata?.["executionGeneration"];
132
+ if (
133
+ callerTurnId !== undefined ||
134
+ callerAttemptId !== undefined ||
135
+ callerExecutionGeneration !== undefined
136
+ ) {
137
+ throw new HTTPException(403, {
138
+ message: "a service initiator cannot replace an exact agent-attempt initiator",
139
+ });
140
+ }
141
+ return {
142
+ initiator: initiator.data,
143
+ context: context.data,
144
+ };
145
+ }
146
+
147
+ function creationInitiatorForGrant(grant: AccessGrant): FrozenCreationInitiator {
148
+ const serviceInitiator = serviceInitiatorForGrant(grant);
149
+ const callerSessionId = grant.metadata?.["sessionId"];
150
+ const callerTurnId = grant.metadata?.["turnId"];
151
+ const callerAttemptId = grant.metadata?.["attemptId"];
152
+ const callerExecutionGeneration = grant.metadata?.["executionGeneration"];
153
+ const hasCallerTurnClaim =
154
+ callerTurnId !== undefined ||
155
+ callerAttemptId !== undefined ||
156
+ callerExecutionGeneration !== undefined;
157
+ if (hasCallerTurnClaim) {
158
+ if (
159
+ typeof callerSessionId !== "string" ||
160
+ typeof callerTurnId !== "string" ||
161
+ typeof callerAttemptId !== "string" ||
162
+ typeof callerExecutionGeneration !== "number" ||
163
+ !Number.isSafeInteger(callerExecutionGeneration) ||
164
+ callerExecutionGeneration < 1
165
+ ) {
166
+ throw new HTTPException(403, { message: "caller attempt claims are incomplete" });
167
+ }
168
+ const actor = {
169
+ type: "agent_attempt",
170
+ sessionId: callerSessionId,
171
+ turnId: callerTurnId,
172
+ attemptId: callerAttemptId,
173
+ executionGeneration: callerExecutionGeneration,
174
+ } as const;
175
+ // The DB create transaction validates this exact attempt and derives the
176
+ // inherited subject under the same locks as the child-session insert.
177
+ return { actor };
178
+ }
179
+ if (serviceInitiator) {
180
+ return serviceInitiator;
181
+ }
182
+ return {
183
+ initiator: {
184
+ kind: "subject",
185
+ subjectId: grant.subjectId,
186
+ ...(grant.subjectLabel ? { label: grant.subjectLabel } : {}),
187
+ },
188
+ context: {},
189
+ };
190
+ }
191
+
87
192
  function normalizedSessionMcpCredentialHeaders(
88
193
  headers: Record<string, string> | undefined,
89
194
  ): Record<string, string> {
@@ -133,6 +238,22 @@ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpS
133
238
  ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
134
239
  cacheToolsList: server.cacheToolsList ?? false,
135
240
  ...(server.requireApproval !== undefined ? { requireApproval: server.requireApproval } : {}),
241
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
242
+ };
243
+ }
244
+
245
+ function mcpServerConfigFromStoredInput(
246
+ server: CreateSessionMcpServerInput,
247
+ ): Settings["mcpServers"][number] {
248
+ return {
249
+ id: server.id,
250
+ ...(server.name ? { name: server.name } : {}),
251
+ url: server.url,
252
+ ...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
253
+ ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
254
+ cacheToolsList: server.cacheToolsList ?? false,
255
+ ...(server.requireApproval != null ? { requireApproval: server.requireApproval } : {}),
256
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
136
257
  };
137
258
  }
138
259
 
@@ -144,6 +265,7 @@ function mcpServerConfigFromMetadata(
144
265
  ...(server.name ? { name: server.name } : {}),
145
266
  url: server.url,
146
267
  cacheToolsList: false,
268
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
147
269
  };
148
270
  }
149
271
 
@@ -177,7 +299,9 @@ function validateSessionMcpServersForCreate(
177
299
  return { runtimeServers: [], dbServers: [], metadata: [] };
178
300
  }
179
301
  requirePermission(grant, "mcp_servers:attach");
180
- const encryptionKey = requireVariableSetEncryption(settings);
302
+ const encryptionKey = servers.some((server) => Object.keys(server.headers ?? {}).length > 0)
303
+ ? requireVariableSetEncryption(settings)
304
+ : null;
181
305
  const existingIds = new Set(settings.mcpServers.map((server) => server.id));
182
306
  const seenIds = new Set<string>();
183
307
  const runtimeServers: Settings["mcpServers"] = [];
@@ -195,7 +319,7 @@ function validateSessionMcpServersForCreate(
195
319
  const headersEncrypted = Object.fromEntries(
196
320
  Object.entries(headers).map(([name, value]) => [
197
321
  name,
198
- encryptVariableSetValue(encryptionKey, value),
322
+ encryptVariableSetValue(encryptionKey!, value),
199
323
  ]),
200
324
  );
201
325
  runtimeServers.push(mcpServerConfigFromInput(server));
@@ -207,6 +331,7 @@ function validateSessionMcpServersForCreate(
207
331
  timeoutMs: server.timeoutMs ?? null,
208
332
  cacheToolsList: server.cacheToolsList ?? false,
209
333
  requireApproval: server.requireApproval ?? null,
334
+ connectionRef: server.connectionRef ?? null,
210
335
  headersEncrypted,
211
336
  });
212
337
  metadata.push({
@@ -215,11 +340,54 @@ function validateSessionMcpServersForCreate(
215
340
  url: server.url,
216
341
  headerNames: Object.keys(headersEncrypted).sort(),
217
342
  credentialVersion: 1,
343
+ connectionRef: server.connectionRef ?? null,
218
344
  });
219
345
  }
220
346
  return { runtimeServers, dbServers, metadata };
221
347
  }
222
348
 
349
+ function validateInheritedSessionMcpServersForCreate(
350
+ servers: CreateSessionMcpServerInput[],
351
+ ): ValidatedSessionMcpServers {
352
+ if (servers.length === 0) {
353
+ return { runtimeServers: [], dbServers: [], metadata: [] };
354
+ }
355
+ const seenIds = new Set<string>();
356
+ for (const server of servers) {
357
+ if (seenIds.has(server.id)) {
358
+ throw new HTTPException(422, {
359
+ message: `duplicate inherited session MCP server id: ${server.id}`,
360
+ });
361
+ }
362
+ seenIds.add(server.id);
363
+ if (reservedSessionMcpServerIds.has(server.id)) {
364
+ throw new HTTPException(422, {
365
+ message: `reserved inherited session MCP server id: ${server.id}`,
366
+ });
367
+ }
368
+ }
369
+ // A newly enabled deployment/workspace capability may now reuse an id that
370
+ // belonged to this parent attachment first. Preserve the parent's existing
371
+ // session-overlay precedence instead of making child creation depend on a
372
+ // later workspace setting; settingsWithSessionMcpServerConfigs performs that
373
+ // same overlay for ordinary parent turns.
374
+ return {
375
+ runtimeServers: servers.map(mcpServerConfigFromStoredInput),
376
+ dbServers: servers.map((server) => ({
377
+ ...server,
378
+ headersEncrypted: { ...(server.headersEncrypted ?? {}) },
379
+ })),
380
+ metadata: servers.map((server) => ({
381
+ id: server.id,
382
+ name: server.name ?? null,
383
+ url: server.url,
384
+ headerNames: Object.keys(server.headersEncrypted ?? {}).sort(),
385
+ credentialVersion: 1,
386
+ connectionRef: server.connectionRef ?? null,
387
+ })),
388
+ };
389
+ }
390
+
223
391
  function validateSessionMcpCredentialUpdates(input: {
224
392
  settings: Settings;
225
393
  grant: AccessGrant;
@@ -258,12 +426,14 @@ function validateSessionMcpCredentialUpdates(input: {
258
426
  }
259
427
 
260
428
  export async function createAndStartSession(input: {
429
+ requestedSessionId?: string;
261
430
  db: Database;
262
431
  bus: EventBus;
263
432
  workflowClient: SessionWorkflowClient;
264
433
  accountId: string;
265
434
  workspaceId: string;
266
435
  initialMessage: string;
436
+ turnInstructions?: string | null;
267
437
  resources: ResourceRef[];
268
438
  tools: ToolRef[];
269
439
  clientEventId?: string;
@@ -271,6 +441,9 @@ export async function createAndStartSession(input: {
271
441
  reasoningEffort: Settings["openaiReasoningEffort"];
272
442
  sandboxBackend: Settings["sandboxBackend"];
273
443
  metadata: Record<string, unknown>;
444
+ createdBy?: TurnInitiator;
445
+ createdByContext?: TurnInitiatorContext;
446
+ createdByActor?: Extract<SessionCommandActor, { type: "agent_attempt" }> | null;
274
447
  // Names/ids only; the session.created payload never carries variable values.
275
448
  variableSet?: { id: string; name: string } | null;
276
449
  // The rig + frozen active rig version resolved at create (M3). Both null ⇒ a
@@ -317,7 +490,7 @@ export async function createAndStartSession(input: {
317
490
  // `workingDir` (optional) is the path/cwd base the chosen machine runs under,
318
491
  // seeded alongside the pointer through the epoch-fenced CAS.
319
492
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
320
- }) {
493
+ }): Promise<CreateSessionResponse> {
321
494
  const sessionMetadata = {
322
495
  ...input.metadata,
323
496
  model: input.model,
@@ -332,6 +505,9 @@ export async function createAndStartSession(input: {
332
505
  input.createIdempotencyKey,
333
506
  );
334
507
  if (existing) {
508
+ if (input.requestedSessionId && existing.id !== input.requestedSessionId) {
509
+ throw new SessionIdConflictError(input.requestedSessionId);
510
+ }
335
511
  return await finishStartSession(
336
512
  existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
337
513
  existing,
@@ -344,12 +520,17 @@ export async function createAndStartSession(input: {
344
520
  // advances the coalesced wake revision so an in-flight stale delivery can
345
521
  // never acknowledge work committed by the other caller.
346
522
  const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
523
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
347
524
  accountId: input.accountId,
348
525
  workspaceId: input.workspaceId,
349
526
  initialMessage: input.initialMessage,
527
+ initialTurnInstructions: input.turnInstructions ?? null,
350
528
  resources: input.resources,
351
529
  tools: input.tools,
352
530
  metadata: sessionMetadata,
531
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
532
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
533
+ createdByActor: input.createdByActor ?? null,
353
534
  model: input.model,
354
535
  sandboxBackend: input.sandboxBackend,
355
536
  variableSetId: input.variableSet?.id ?? null,
@@ -372,12 +553,17 @@ export async function createAndStartSession(input: {
372
553
  return await finishStartSession(input, keyed);
373
554
  }
374
555
  const session = await createSession(input.db, {
556
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
375
557
  accountId: input.accountId,
376
558
  workspaceId: input.workspaceId,
377
559
  initialMessage: input.initialMessage,
560
+ initialTurnInstructions: input.turnInstructions ?? null,
378
561
  resources: input.resources,
379
562
  tools: input.tools,
380
563
  metadata: sessionMetadata,
564
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
565
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
566
+ createdByActor: input.createdByActor ?? null,
381
567
  model: input.model,
382
568
  sandboxBackend: input.sandboxBackend,
383
569
  variableSetId: input.variableSet?.id ?? null,
@@ -405,6 +591,7 @@ async function finishStartSession(
405
591
  bus: EventBus;
406
592
  workflowClient: SessionWorkflowClient;
407
593
  initialMessage: string;
594
+ turnInstructions?: string | null;
408
595
  resources: ResourceRef[];
409
596
  tools: ToolRef[];
410
597
  clientEventId?: string;
@@ -421,7 +608,7 @@ async function finishStartSession(
421
608
  } | null;
422
609
  },
423
610
  session: Session,
424
- ): Promise<Session> {
611
+ ): Promise<CreateSessionResponse> {
425
612
  // Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
426
613
  // the atomic initial turn transaction, so the FIRST turn routes to the chosen
427
614
  // machine. swapActiveSandbox does
@@ -488,7 +675,12 @@ async function finishStartSession(
488
675
  wakeRevision: started.workflowWakeRevision,
489
676
  });
490
677
  }
491
- return await requireSession(input.db, session.workspaceId, session.id);
678
+ const persisted = await requireSession(input.db, session.workspaceId, session.id);
679
+ const initialTurnId =
680
+ started.turn?.id ??
681
+ (await listSessionTurns(input.db, session.workspaceId, session.id, 1))[0]?.id ??
682
+ null;
683
+ return { ...persisted, initialTurnId };
492
684
  }
493
685
 
494
686
  export function workflowIdForSession(sessionId: string): string {
@@ -607,6 +799,7 @@ export async function postUserMessageTurn(input: {
607
799
  workspaceId: string;
608
800
  sessionId: string;
609
801
  text: string;
802
+ turnInstructions?: string | null;
610
803
  resources: ResourceRef[];
611
804
  tools: ToolRef[];
612
805
  model?: string | null;
@@ -616,6 +809,8 @@ export async function postUserMessageTurn(input: {
616
809
  delivery?: "send" | "steer";
617
810
  origin?: "human" | "operator";
618
811
  actor?: string;
812
+ actorLabel?: string;
813
+ commandActor?: SessionCommandActor;
619
814
  controlEtag?: string | null;
620
815
  expectedDraftRevision?: number | null;
621
816
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
@@ -636,12 +831,17 @@ export async function postUserMessageTurn(input: {
636
831
  workspaceId,
637
832
  sessionId,
638
833
  subjectId: input.actor ?? accountId,
639
- actor: { type: "human", subjectId: input.actor ?? accountId },
834
+ ...(input.actorLabel ? { subjectLabel: input.actorLabel } : {}),
835
+ actor: input.commandActor ?? {
836
+ type: "human",
837
+ subjectId: input.actor ?? accountId,
838
+ },
640
839
  operationKey,
641
840
  delivery: input.delivery ?? "send",
642
841
  controlEtag: input.controlEtag ?? null,
643
842
  expectedDraftRevision: input.expectedDraftRevision ?? null,
644
843
  text: input.text,
844
+ turnInstructions: input.turnInstructions ?? null,
645
845
  resources: input.resources,
646
846
  tools: input.tools,
647
847
  model: requestedModel,
@@ -718,8 +918,10 @@ export async function postUserMessageTurn(input: {
718
918
  * Full create-session flow shared by `POST /sessions` and the first-party MCP
719
919
  * `session_create` tool: payload validation, resource/tool/variableSet
720
920
  * checks, usage limits, session start, and usage recording. `rawPayload` is
721
- * the unparsed request body so absent-vs-empty `tools` keeps its meaning
722
- * (absent applies the workspace's default capability MCP tools).
921
+ * the unparsed request body so absent-vs-empty execution-context fields keep
922
+ * their meaning: a child inherits omitted resources/tools/mcpServers from its
923
+ * trusted immediate parent, while explicit arrays (including []) win. A
924
+ * top-level create with omitted tools applies workspace-default capability MCPs.
723
925
  */
724
926
  export async function createSessionForRequest(
725
927
  deps: ApiRouteDeps,
@@ -729,25 +931,56 @@ export async function createSessionForRequest(
729
931
  ): Promise<Session> {
730
932
  const { settings, db, bus, workflowClient, objectStorage } = deps;
731
933
  const payload = CreateSessionRequest.parse(rawPayload);
934
+ // Parent linkage and execution-context inheritance come ONLY from the
935
+ // worker-signed sessionId claim. A caller cannot nominate a parent in the
936
+ // payload, so inheriting an existing repository/tool/credential snapshot does
937
+ // not turn sessions:create into arbitrary cross-session read authority.
938
+ const parentSessionId =
939
+ typeof grant.metadata?.["sessionId"] === "string"
940
+ ? (grant.metadata["sessionId"] as string)
941
+ : null;
942
+ if (parentSessionId) {
943
+ await requireSessionAuthorization(deps, grant, {
944
+ sessionId: parentSessionId,
945
+ operation: "session.child.create",
946
+ surface: "core",
947
+ });
948
+ }
949
+ const parentSession = parentSessionId ? await getSession(db, workspaceId, parentSessionId) : null;
950
+ if (parentSessionId && !parentSession) {
951
+ throw new HTTPException(404, {
952
+ message: `parent session not found in workspace: ${parentSessionId}`,
953
+ });
954
+ }
732
955
  const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
733
956
  db,
734
957
  workspaceId,
735
958
  settings,
736
959
  );
737
- const sessionMcpServers = validateSessionMcpServersForCreate(
738
- capabilityRuntimeSettings,
739
- grant,
740
- payload.mcpServers,
741
- );
960
+ const sessionMcpServers = hasOwnProperty(rawPayload, "mcpServers")
961
+ ? validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers)
962
+ : parentSession
963
+ ? validateInheritedSessionMcpServersForCreate(
964
+ await listSessionMcpServersForChildInheritance(db, workspaceId, parentSession.id),
965
+ )
966
+ : validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers);
742
967
  const runtimeSettings = settingsWithSessionMcpServerConfigs(
743
968
  capabilityRuntimeSettings,
744
969
  sessionMcpServers.runtimeServers,
745
970
  );
746
- const resources = normalizeResources(payload.resources);
747
- const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
748
- const defaultedTools = hasOwnProperty(rawPayload, "tools")
749
- ? requestedTools
750
- : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
971
+ const resources = normalizeResources(
972
+ hasOwnProperty(rawPayload, "resources")
973
+ ? payload.resources
974
+ : (parentSession?.resources ?? payload.resources),
975
+ );
976
+ const requestedTools = validateToolRefs(
977
+ hasOwnProperty(rawPayload, "tools") ? payload.tools : (parentSession?.tools ?? payload.tools),
978
+ runtimeSettings,
979
+ );
980
+ const defaultedTools =
981
+ hasOwnProperty(rawPayload, "tools") || parentSession
982
+ ? requestedTools
983
+ : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
751
984
  // The first-party MCP server is attached to EVERY session. It hosts the
752
985
  // session's own metadata tool (set_session_title) + goal tools, and — only
753
986
  // when the grant carries the permission — the orchestration/variableSet/
@@ -813,11 +1046,18 @@ export async function createSessionForRequest(
813
1046
  );
814
1047
  const model = payload.model ?? settings.openaiModel;
815
1048
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
1049
+ // Parent linkage was resolved above, before context validation. A child with
1050
+ // no explicit permission override inherits the creating session's effective
1051
+ // grant instead of silently expanding to standalone worker defaults.
816
1052
  // A session's first-party MCP token can carry a non-default permission set
817
1053
  // (how an operator hands a manager-style session the orchestration tools),
818
1054
  // but never one out-ranking its creator: every requested permission must be
819
- // held by the creating grant.
820
- let firstPartyMcpPermissions = payload.firstPartyMcpPermissions ?? null;
1055
+ // held by the creating grant. A top-level omission keeps the deployment's
1056
+ // normal worker defaults. A child omission inherits its creator's exact
1057
+ // effective grant, preserving a host/operator's narrowed capability boundary
1058
+ // through the whole session tree.
1059
+ let firstPartyMcpPermissions =
1060
+ payload.firstPartyMcpPermissions ?? (parentSessionId ? [...new Set(grant.permissions)] : null);
821
1061
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
822
1062
  // An empty set would sign an unusable zero-permission token; the default
823
1063
  // worker set is expressed by omitting the field.
@@ -833,21 +1073,21 @@ export async function createSessionForRequest(
833
1073
  });
834
1074
  }
835
1075
  }
836
- // Invariant: a goal-bearing session always carries goals:manage in its
837
- // effective first-party permissions. Without it the worker's delegated
838
- // token never sees the goal tools (goal_complete/goal_pause/...), so the
839
- // agent cannot stop its own goal and the continuation loop runs until an
840
- // operator intervenes. The auto-added permission is deliberately exempt
841
- // from the creating-grant check above: goal tools are scoped to the
842
- // spawned session itself via the worker-signed sessionId claim, so a
843
- // worker managing its OWN goal is not an escalation of the spawner's
844
- // authority.
1076
+ // A goal-bearing session with an explicit/effective permission set must
1077
+ // already carry goals:manage. Without it the worker cannot stop its own
1078
+ // continuation loop, but silently adding it would violate the child
1079
+ // authority contract: a child inherits or narrows its creator's exact grant
1080
+ // and never gains an unrequested permission. Top-level omission remains the
1081
+ // deployment's worker default, which includes the goal tools.
845
1082
  if (
846
1083
  payload.goal &&
847
1084
  firstPartyMcpPermissions &&
848
1085
  !firstPartyMcpPermissions.includes("goals:manage")
849
1086
  ) {
850
- firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
1087
+ throw new HTTPException(422, {
1088
+ message:
1089
+ "goal-bearing sessions require goals:manage in the resulting first-party MCP permission set",
1090
+ });
851
1091
  }
852
1092
  // Parent linkage: a worker is linked to its manager ONLY from the
853
1093
  // worker-signed sessionId claim on the creating grant — the manager
@@ -860,10 +1100,6 @@ export async function createSessionForRequest(
860
1100
  // its completion wake injects a user.message + queued turn into that session
861
1101
  // without holding sessions:control on it (a cross-session write escalation).
862
1102
  // The claim is the only trustworthy parent source.
863
- const parentSessionId =
864
- typeof grant.metadata?.["sessionId"] === "string"
865
- ? (grant.metadata["sessionId"] as string)
866
- : null;
867
1103
  // Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
868
1104
  //
869
1105
  // The DEFAULT rule is context-dependent and resolved server-side from the
@@ -921,12 +1157,10 @@ export async function createSessionForRequest(
921
1157
  "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create.",
922
1158
  });
923
1159
  }
924
- const parent = await getSession(db, workspaceId, parentSessionId);
925
- if (!parent) {
926
- throw new HTTPException(404, {
927
- message: `parent session not found in workspace: ${parentSessionId}`,
928
- });
1160
+ if (!parentSession) {
1161
+ throw new Error("trusted parent session was not resolved");
929
1162
  }
1163
+ const parent = parentSession;
930
1164
  const parentBoxed = parent.sandboxBackend !== "none";
931
1165
  const variableSetMismatch =
932
1166
  parentBoxed && !variableSetMatchesGroup(parent.variableSetId ?? null);
@@ -1074,53 +1308,72 @@ export async function createSessionForRequest(
1074
1308
  quantity: 1,
1075
1309
  model,
1076
1310
  });
1077
- const session = await createAndStartSession({
1078
- db,
1079
- bus,
1080
- workflowClient,
1081
- accountId: grant.accountId,
1082
- workspaceId,
1083
- initialMessage: payload.initialMessage,
1084
- resources,
1085
- tools,
1086
- ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1087
- model,
1088
- reasoningEffort,
1089
- // A shared spawn inherits the box's backend; a caller-supplied
1090
- // sandboxBackend on a shared spawn is ignored (it is the same box). A
1091
- // machine-targeted top-level create labels the home "selfhosted"
1092
- // (machineHomeBackend), overriding the caller/deployment default so the row
1093
- // matches where the session actually runs.
1094
- sandboxBackend:
1095
- inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
1096
- // Mirror the backend relabel on the OS axis: only a machine-targeted
1097
- // top-level create carries a derived OS; everything else is omitted and the
1098
- // "linux" default holds (shared spawns keep the parent-box behavior).
1099
- ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
1100
- sandboxGroupId,
1101
- metadata: payload.metadata,
1102
- variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
1103
- // Frozen rig binding (M3): both null for a rig-less session (today's path).
1104
- rigId: frozenRigId,
1105
- rigVersionId: frozenRigVersionId,
1106
- goal: payload.goal ?? null,
1107
- // Per-session persona instructions (already trimmed/validated by the
1108
- // contracts schema). Persisted on the row; composed system-level at turn
1109
- // time. Not surfaced as an event.
1110
- instructions: payload.instructions ?? null,
1111
- firstPartyMcpPermissions,
1112
- mcpServers: sessionMcpServers.dbServers,
1113
- sessionMcpServers: sessionMcpServers.metadata,
1114
- parentSessionId,
1115
- createIdempotencyKey: payload.idempotencyKey ?? null,
1116
- // Create-time machine targeting (A-2a): when a target sandbox is named, the
1117
- // active-sandbox pointer is seeded race-free inside createAndStartSession
1118
- // (after the row exists, before the first turn dispatches). Validation
1119
- // (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
1120
- seedTargetSandbox: payload.targetSandboxId
1121
- ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
1122
- : null,
1123
- });
1311
+ const creationInitiator = creationInitiatorForGrant(grant);
1312
+ let session: CreateSessionResponse;
1313
+ try {
1314
+ session = await createAndStartSession({
1315
+ ...(payload.requestedSessionId ? { requestedSessionId: payload.requestedSessionId } : {}),
1316
+ db,
1317
+ bus,
1318
+ workflowClient,
1319
+ accountId: grant.accountId,
1320
+ workspaceId,
1321
+ initialMessage: payload.initialMessage,
1322
+ turnInstructions: payload.turnInstructions ?? null,
1323
+ resources,
1324
+ tools,
1325
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1326
+ model,
1327
+ reasoningEffort,
1328
+ // A shared spawn inherits the box's backend; a caller-supplied
1329
+ // sandboxBackend on a shared spawn is ignored (it is the same box). A
1330
+ // machine-targeted top-level create labels the home "selfhosted"
1331
+ // (machineHomeBackend), overriding the caller/deployment default so the row
1332
+ // matches where the session actually runs.
1333
+ sandboxBackend:
1334
+ inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
1335
+ // Mirror the backend relabel on the OS axis: only a machine-targeted
1336
+ // top-level create carries a derived OS; everything else is omitted and the
1337
+ // "linux" default holds (shared spawns keep the parent-box behavior).
1338
+ ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
1339
+ sandboxGroupId,
1340
+ metadata: payload.metadata,
1341
+ ...(creationInitiator.initiator ? { createdBy: creationInitiator.initiator } : {}),
1342
+ ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
1343
+ createdByActor: creationInitiator.actor ?? null,
1344
+ variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
1345
+ // Frozen rig binding (M3): both null for a rig-less session (today's path).
1346
+ rigId: frozenRigId,
1347
+ rigVersionId: frozenRigVersionId,
1348
+ goal: payload.goal ?? null,
1349
+ // Per-session persona instructions (already trimmed/validated by the
1350
+ // contracts schema). Persisted on the row; composed system-level at turn
1351
+ // time. Not surfaced as an event.
1352
+ instructions: payload.instructions ?? null,
1353
+ firstPartyMcpPermissions,
1354
+ mcpServers: sessionMcpServers.dbServers,
1355
+ sessionMcpServers: sessionMcpServers.metadata,
1356
+ parentSessionId,
1357
+ createIdempotencyKey: payload.idempotencyKey ?? null,
1358
+ // Create-time machine targeting (A-2a): when a target sandbox is named, the
1359
+ // active-sandbox pointer is seeded race-free inside createAndStartSession
1360
+ // (after the row exists, before the first turn dispatches). Validation
1361
+ // (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
1362
+ seedTargetSandbox: payload.targetSandboxId
1363
+ ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
1364
+ : null,
1365
+ });
1366
+ } catch (error) {
1367
+ if (error instanceof AgentCommandAuthorityError) {
1368
+ throw new HTTPException(403, { message: error.message });
1369
+ }
1370
+ if (error instanceof SessionIdConflictError) {
1371
+ throw new HTTPException(409, {
1372
+ message: "requested session id is already in use",
1373
+ });
1374
+ }
1375
+ throw error;
1376
+ }
1124
1377
  await recordWorkspaceUsage(deps, {
1125
1378
  accountId: grant.accountId,
1126
1379
  workspaceId,
@@ -1130,6 +1383,10 @@ export async function createSessionForRequest(
1130
1383
  unit: "run",
1131
1384
  sourceResourceType: "session",
1132
1385
  sourceResourceId: session.id,
1386
+ sessionId: session.id,
1387
+ initiator: session.createdBy,
1388
+ initiatorContext: session.createdByContext,
1389
+ origin: creationInitiator.actor ? "system" : "user",
1133
1390
  idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`,
1134
1391
  });
1135
1392
  return session;
@@ -1149,6 +1406,7 @@ export async function acceptSessionUserMessage(
1149
1406
  sessionId: string,
1150
1407
  input: {
1151
1408
  text: string;
1409
+ turnInstructions?: string | null;
1152
1410
  resources?: ResourceRef[];
1153
1411
  tools?: ToolRef[];
1154
1412
  toolsProvided: boolean;
@@ -1163,6 +1421,11 @@ export async function acceptSessionUserMessage(
1163
1421
  },
1164
1422
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1165
1423
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1424
+ await requireSessionAuthorization(deps, grant, {
1425
+ sessionId,
1426
+ operation: input.delivery === "steer" ? "session.steer" : "session.append",
1427
+ surface: "core",
1428
+ });
1166
1429
  const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1167
1430
  db,
1168
1431
  workspaceId,
@@ -1202,6 +1465,7 @@ export async function acceptSessionUserMessage(
1202
1465
  session: existingSession,
1203
1466
  updates: input.mcpCredentialUpdates ?? [],
1204
1467
  });
1468
+ const delegatedServiceInitiator = serviceInitiatorForGrant(grant);
1205
1469
  const { accepted, turn } = await postUserMessageTurn({
1206
1470
  db,
1207
1471
  bus,
@@ -1211,14 +1475,28 @@ export async function acceptSessionUserMessage(
1211
1475
  workspaceId,
1212
1476
  sessionId,
1213
1477
  text: input.text,
1478
+ turnInstructions: input.turnInstructions ?? null,
1214
1479
  resources: requestedResources,
1215
1480
  tools: requestedTools,
1216
1481
  model: input.model ?? null,
1217
1482
  reasoningEffort: input.reasoningEffort ?? null,
1218
1483
  mcpCredentialUpdates,
1219
1484
  delivery: input.delivery ?? "send",
1220
- origin: input.origin ?? "human",
1485
+ origin: delegatedServiceInitiator ? "operator" : (input.origin ?? "human"),
1221
1486
  actor: grant.subjectId,
1487
+ ...(grant.subjectLabel ? { actorLabel: grant.subjectLabel } : {}),
1488
+ ...(delegatedServiceInitiator
1489
+ ? {
1490
+ commandActor: {
1491
+ type: "service" as const,
1492
+ subjectId: delegatedServiceInitiator.initiator.subjectId,
1493
+ ...(delegatedServiceInitiator.initiator.label
1494
+ ? { subjectLabel: delegatedServiceInitiator.initiator.label }
1495
+ : {}),
1496
+ context: delegatedServiceInitiator.context,
1497
+ },
1498
+ }
1499
+ : {}),
1222
1500
  ...(input.controlEtag !== undefined ? { controlEtag: input.controlEtag } : {}),
1223
1501
  ...(input.expectedDraftRevision !== undefined
1224
1502
  ? { expectedDraftRevision: input.expectedDraftRevision }
@@ -1234,6 +1512,11 @@ export async function acceptSessionUserMessage(
1234
1512
  unit: "run",
1235
1513
  sourceResourceType: "session_turn",
1236
1514
  sourceResourceId: turn.id,
1515
+ sessionId,
1516
+ turnId: turn.id,
1517
+ initiator: turn.initiator,
1518
+ initiatorContext: turn.initiatorContext,
1519
+ origin: turn.source,
1237
1520
  idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`,
1238
1521
  });
1239
1522
  return { accepted, turn };
@@ -1249,13 +1532,27 @@ export async function acceptSessionUserMessage(
1249
1532
  * happened so callers can avoid double work.
1250
1533
  */
1251
1534
  export async function updateSessionTitle(
1252
- deps: { db: Database; bus: EventBus },
1253
- workspaceId: string,
1535
+ deps: {
1536
+ db: Database;
1537
+ bus: EventBus;
1538
+ sessionAuthorization?: SessionAuthorizationPort | null;
1539
+ },
1540
+ grant: AccessGrant,
1254
1541
  sessionId: string,
1255
1542
  title: string,
1256
1543
  source: "user" | "agent",
1257
- ): Promise<{ updated: boolean; title: string | null }> {
1544
+ ): Promise<{
1545
+ updated: boolean;
1546
+ title: string | null;
1547
+ relatedSessionAccess: "target" | "root";
1548
+ }> {
1258
1549
  const { db, bus } = deps;
1550
+ const authorization = await requireSessionAuthorization(deps, grant, {
1551
+ sessionId,
1552
+ operation: "session.title.write",
1553
+ surface: "core",
1554
+ });
1555
+ const workspaceId = grant.workspaceId;
1259
1556
  const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
1260
1557
  if (result.updated) {
1261
1558
  await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
@@ -1268,11 +1565,30 @@ export async function updateSessionTitle(
1268
1565
  },
1269
1566
  ]);
1270
1567
  }
1271
- return result;
1568
+ return {
1569
+ ...result,
1570
+ relatedSessionAccess: authorization?.relatedSessionAccess ?? "root",
1571
+ };
1272
1572
  }
1273
1573
 
1274
- export async function readSessionLineage(db: Database, workspaceId: string, sessionId: string) {
1275
- const lineage = await getSessionLineage(db, workspaceId, sessionId);
1574
+ export async function readSessionLineage(
1575
+ deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1576
+ grant: AccessGrant,
1577
+ sessionId: string,
1578
+ ) {
1579
+ const authorization = await requireSessionAuthorization(deps, grant, {
1580
+ sessionId,
1581
+ operation: "session.lineage.read",
1582
+ surface: "core",
1583
+ });
1584
+ if (authorization?.relatedSessionAccess === "target") {
1585
+ const session = await getSession(deps.db, grant.workspaceId, sessionId);
1586
+ if (!session) {
1587
+ throw new HTTPException(404, { message: "session not found" });
1588
+ }
1589
+ return { ancestors: [], children: [], truncated: false };
1590
+ }
1591
+ const lineage = await getSessionLineage(deps.db, grant.workspaceId, sessionId);
1276
1592
  if (!lineage) {
1277
1593
  throw new HTTPException(404, { message: "session not found" });
1278
1594
  }