@opengeni/core 0.4.10 → 0.10.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.
@@ -1,21 +1,38 @@
1
1
  import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
2
- import { configuredAllowedModels, policyProviderIdForModel, type Settings } from "@opengeni/config";
2
+ import {
3
+ canonicalizeConfiguredModelId,
4
+ configuredAllowedModels,
5
+ policyProviderIdForModel,
6
+ resolveTurnExecutionPolicyV1,
7
+ type Settings,
8
+ } from "@opengeni/config";
3
9
  import {
4
10
  CreateSessionRequest,
11
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
12
+ ServiceTurnInitiator,
13
+ ServiceTurnInitiatorContext,
5
14
  evaluateWorkspaceModelPolicy,
6
15
  reasoningEffortForMetadata,
7
16
  type AccessGrant,
17
+ type CreateSessionResponse,
8
18
  type GoalSpec,
9
19
  type Permission,
10
20
  type ReasoningEffort,
11
21
  type ResourceRef,
12
22
  type Session,
13
23
  type SessionEvent,
24
+ SessionMcpApprovalPolicy,
14
25
  type SessionMcpCredentialUpdateInput,
15
26
  type SessionMcpServerInput,
16
27
  type SessionMcpServerMetadata,
28
+ type UpdateSessionMcpApprovalPolicyResponse,
29
+ type SessionAuthorizationPort,
30
+ type SessionToolPolicy,
17
31
  type SessionTurn,
18
32
  type ToolRef,
33
+ type TurnInitiator,
34
+ type TurnInitiatorContext,
35
+ type TurnExecutionPolicyV1,
19
36
  } from "@opengeni/contracts";
20
37
  import {
21
38
  createSession,
@@ -29,6 +46,7 @@ import {
29
46
  listDistinctRigVersionIdsInGroup,
30
47
  getSandbox,
31
48
  getSession,
49
+ SessionIdConflictError,
32
50
  getSessionByCreateIdempotencyKey,
33
51
  getSessionEvent,
34
52
  getWorkspaceControlEvent,
@@ -36,15 +54,20 @@ import {
36
54
  getSessionTurn,
37
55
  getWorkspaceModelPolicy,
38
56
  initializeSessionStartAtomically,
57
+ listSessionTurns,
58
+ listSessionMcpServersForChildInheritance,
39
59
  requireSession,
40
60
  submitHumanPromptInTransaction,
61
+ appendSessionEventsWithLockedSessionUpdate,
41
62
  updateSessionTitle as updateSessionTitleRow,
42
63
  withWorkspaceSubjectRls,
43
64
  type CreateSessionMcpServerInput,
44
65
  type Database,
45
66
  type UpdateSessionMcpServerCredentialsInput,
46
67
  QueueCommandConflictError,
68
+ AgentCommandAuthorityError,
47
69
  SessionControlConflictError,
70
+ type SessionCommandActor,
48
71
  } from "@opengeni/db";
49
72
  import {
50
73
  appendAndPublishEvents,
@@ -60,15 +83,19 @@ import type {
60
83
  ApiRouteDeps,
61
84
  SessionWorkflowClient,
62
85
  } from "../dependencies";
86
+ import { requireSessionAuthorization } from "../session-authorization";
63
87
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
64
88
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
65
89
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
66
90
  import {
91
+ assertToolRefsSubset,
92
+ availableToolRefs,
67
93
  mergeToolRefs,
68
94
  normalizeResources,
69
95
  validateFileResources,
70
96
  validateGitHubRepositorySelection,
71
97
  validateToolRefs,
98
+ validateToolRefsForSessionPolicy,
72
99
  withDefaultEnabledCapabilityMcpTools,
73
100
  } from "./resources";
74
101
 
@@ -84,6 +111,99 @@ type ValidatedSessionMcpServers = {
84
111
  metadata: SessionMcpServerMetadata[];
85
112
  };
86
113
 
114
+ type FrozenCreationInitiator = {
115
+ initiator?: TurnInitiator;
116
+ context?: TurnInitiatorContext;
117
+ actor?: Extract<SessionCommandActor, { type: "agent_attempt" }>;
118
+ };
119
+
120
+ function serviceInitiatorForGrant(grant: AccessGrant): {
121
+ initiator: ServiceTurnInitiator;
122
+ context: ServiceTurnInitiatorContext;
123
+ } | null {
124
+ if (!grant.serviceInitiator) {
125
+ if (grant.serviceInitiatorContext) {
126
+ throw new HTTPException(403, {
127
+ message: "service initiator context requires a signed service initiator",
128
+ });
129
+ }
130
+ return null;
131
+ }
132
+ const initiator = ServiceTurnInitiator.safeParse(grant.serviceInitiator);
133
+ if (!initiator.success) {
134
+ throw new HTTPException(403, {
135
+ message: "a delegated command initiator must be a bounded service principal",
136
+ });
137
+ }
138
+ const context = ServiceTurnInitiatorContext.safeParse(grant.serviceInitiatorContext ?? {});
139
+ if (!context.success) {
140
+ throw new HTTPException(403, {
141
+ message: "delegated service initiator context is invalid or reserved",
142
+ });
143
+ }
144
+ const callerTurnId = grant.metadata?.["turnId"];
145
+ const callerAttemptId = grant.metadata?.["attemptId"];
146
+ const callerExecutionGeneration = grant.metadata?.["executionGeneration"];
147
+ if (
148
+ callerTurnId !== undefined ||
149
+ callerAttemptId !== undefined ||
150
+ callerExecutionGeneration !== undefined
151
+ ) {
152
+ throw new HTTPException(403, {
153
+ message: "a service initiator cannot replace an exact agent-attempt initiator",
154
+ });
155
+ }
156
+ return {
157
+ initiator: initiator.data,
158
+ context: context.data,
159
+ };
160
+ }
161
+
162
+ function creationInitiatorForGrant(grant: AccessGrant): FrozenCreationInitiator {
163
+ const serviceInitiator = serviceInitiatorForGrant(grant);
164
+ const callerSessionId = grant.metadata?.["sessionId"];
165
+ const callerTurnId = grant.metadata?.["turnId"];
166
+ const callerAttemptId = grant.metadata?.["attemptId"];
167
+ const callerExecutionGeneration = grant.metadata?.["executionGeneration"];
168
+ const hasCallerTurnClaim =
169
+ callerTurnId !== undefined ||
170
+ callerAttemptId !== undefined ||
171
+ callerExecutionGeneration !== undefined;
172
+ if (hasCallerTurnClaim) {
173
+ if (
174
+ typeof callerSessionId !== "string" ||
175
+ typeof callerTurnId !== "string" ||
176
+ typeof callerAttemptId !== "string" ||
177
+ typeof callerExecutionGeneration !== "number" ||
178
+ !Number.isSafeInteger(callerExecutionGeneration) ||
179
+ callerExecutionGeneration < 1
180
+ ) {
181
+ throw new HTTPException(403, { message: "caller attempt claims are incomplete" });
182
+ }
183
+ const actor = {
184
+ type: "agent_attempt",
185
+ sessionId: callerSessionId,
186
+ turnId: callerTurnId,
187
+ attemptId: callerAttemptId,
188
+ executionGeneration: callerExecutionGeneration,
189
+ } as const;
190
+ // The DB create transaction validates this exact attempt and derives the
191
+ // inherited subject under the same locks as the child-session insert.
192
+ return { actor };
193
+ }
194
+ if (serviceInitiator) {
195
+ return serviceInitiator;
196
+ }
197
+ return {
198
+ initiator: {
199
+ kind: "subject",
200
+ subjectId: grant.subjectId,
201
+ ...(grant.subjectLabel ? { label: grant.subjectLabel } : {}),
202
+ },
203
+ context: {},
204
+ };
205
+ }
206
+
87
207
  function normalizedSessionMcpCredentialHeaders(
88
208
  headers: Record<string, string> | undefined,
89
209
  ): Record<string, string> {
@@ -133,6 +253,22 @@ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpS
133
253
  ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
134
254
  cacheToolsList: server.cacheToolsList ?? false,
135
255
  ...(server.requireApproval !== undefined ? { requireApproval: server.requireApproval } : {}),
256
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
257
+ };
258
+ }
259
+
260
+ function mcpServerConfigFromStoredInput(
261
+ server: CreateSessionMcpServerInput,
262
+ ): Settings["mcpServers"][number] {
263
+ return {
264
+ id: server.id,
265
+ ...(server.name ? { name: server.name } : {}),
266
+ url: server.url,
267
+ ...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
268
+ ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
269
+ cacheToolsList: server.cacheToolsList ?? false,
270
+ ...(server.requireApproval != null ? { requireApproval: server.requireApproval } : {}),
271
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
136
272
  };
137
273
  }
138
274
 
@@ -144,6 +280,8 @@ function mcpServerConfigFromMetadata(
144
280
  ...(server.name ? { name: server.name } : {}),
145
281
  url: server.url,
146
282
  cacheToolsList: false,
283
+ requireApproval: server.requireApproval,
284
+ ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
147
285
  };
148
286
  }
149
287
 
@@ -177,7 +315,9 @@ function validateSessionMcpServersForCreate(
177
315
  return { runtimeServers: [], dbServers: [], metadata: [] };
178
316
  }
179
317
  requirePermission(grant, "mcp_servers:attach");
180
- const encryptionKey = requireVariableSetEncryption(settings);
318
+ const encryptionKey = servers.some((server) => Object.keys(server.headers ?? {}).length > 0)
319
+ ? requireVariableSetEncryption(settings)
320
+ : null;
181
321
  const existingIds = new Set(settings.mcpServers.map((server) => server.id));
182
322
  const seenIds = new Set<string>();
183
323
  const runtimeServers: Settings["mcpServers"] = [];
@@ -195,7 +335,7 @@ function validateSessionMcpServersForCreate(
195
335
  const headersEncrypted = Object.fromEntries(
196
336
  Object.entries(headers).map(([name, value]) => [
197
337
  name,
198
- encryptVariableSetValue(encryptionKey, value),
338
+ encryptVariableSetValue(encryptionKey!, value),
199
339
  ]),
200
340
  );
201
341
  runtimeServers.push(mcpServerConfigFromInput(server));
@@ -207,6 +347,7 @@ function validateSessionMcpServersForCreate(
207
347
  timeoutMs: server.timeoutMs ?? null,
208
348
  cacheToolsList: server.cacheToolsList ?? false,
209
349
  requireApproval: server.requireApproval ?? null,
350
+ connectionRef: server.connectionRef ?? null,
210
351
  headersEncrypted,
211
352
  });
212
353
  metadata.push({
@@ -215,11 +356,56 @@ function validateSessionMcpServersForCreate(
215
356
  url: server.url,
216
357
  headerNames: Object.keys(headersEncrypted).sort(),
217
358
  credentialVersion: 1,
359
+ requireApproval: server.requireApproval ?? false,
360
+ connectionRef: server.connectionRef ?? null,
218
361
  });
219
362
  }
220
363
  return { runtimeServers, dbServers, metadata };
221
364
  }
222
365
 
366
+ function validateInheritedSessionMcpServersForCreate(
367
+ servers: CreateSessionMcpServerInput[],
368
+ ): ValidatedSessionMcpServers {
369
+ if (servers.length === 0) {
370
+ return { runtimeServers: [], dbServers: [], metadata: [] };
371
+ }
372
+ const seenIds = new Set<string>();
373
+ for (const server of servers) {
374
+ if (seenIds.has(server.id)) {
375
+ throw new HTTPException(422, {
376
+ message: `duplicate inherited session MCP server id: ${server.id}`,
377
+ });
378
+ }
379
+ seenIds.add(server.id);
380
+ if (reservedSessionMcpServerIds.has(server.id)) {
381
+ throw new HTTPException(422, {
382
+ message: `reserved inherited session MCP server id: ${server.id}`,
383
+ });
384
+ }
385
+ }
386
+ // A newly enabled deployment/workspace capability may now reuse an id that
387
+ // belonged to this parent attachment first. Preserve the parent's existing
388
+ // session-overlay precedence instead of making child creation depend on a
389
+ // later workspace setting; settingsWithSessionMcpServerConfigs performs that
390
+ // same overlay for ordinary parent turns.
391
+ return {
392
+ runtimeServers: servers.map(mcpServerConfigFromStoredInput),
393
+ dbServers: servers.map((server) => ({
394
+ ...server,
395
+ headersEncrypted: { ...(server.headersEncrypted ?? {}) },
396
+ })),
397
+ metadata: servers.map((server) => ({
398
+ id: server.id,
399
+ name: server.name ?? null,
400
+ url: server.url,
401
+ headerNames: Object.keys(server.headersEncrypted ?? {}).sort(),
402
+ credentialVersion: 1,
403
+ requireApproval: server.requireApproval ?? false,
404
+ connectionRef: server.connectionRef ?? null,
405
+ })),
406
+ };
407
+ }
408
+
223
409
  function validateSessionMcpCredentialUpdates(input: {
224
410
  settings: Settings;
225
411
  grant: AccessGrant;
@@ -258,19 +444,29 @@ function validateSessionMcpCredentialUpdates(input: {
258
444
  }
259
445
 
260
446
  export async function createAndStartSession(input: {
447
+ requestedSessionId?: string;
261
448
  db: Database;
262
449
  bus: EventBus;
263
450
  workflowClient: SessionWorkflowClient;
264
451
  accountId: string;
265
452
  workspaceId: string;
266
453
  initialMessage: string;
454
+ turnInstructions?: string | null;
267
455
  resources: ResourceRef[];
268
456
  tools: ToolRef[];
457
+ // Public admission always supplies provenance; optional keeps internal
458
+ // callers that predate durable tool-policy provenance source-compatible
459
+ // during the rolling deploy.
460
+ toolPolicy?: SessionToolPolicy;
269
461
  clientEventId?: string;
270
462
  model: string;
271
463
  reasoningEffort: Settings["openaiReasoningEffort"];
464
+ turnExecutionPolicy: TurnExecutionPolicyV1;
272
465
  sandboxBackend: Settings["sandboxBackend"];
273
466
  metadata: Record<string, unknown>;
467
+ createdBy?: TurnInitiator;
468
+ createdByContext?: TurnInitiatorContext;
469
+ createdByActor?: Extract<SessionCommandActor, { type: "agent_attempt" }> | null;
274
470
  // Names/ids only; the session.created payload never carries variable values.
275
471
  variableSet?: { id: string; name: string } | null;
276
472
  // The rig + frozen active rig version resolved at create (M3). Both null ⇒ a
@@ -317,7 +513,7 @@ export async function createAndStartSession(input: {
317
513
  // `workingDir` (optional) is the path/cwd base the chosen machine runs under,
318
514
  // seeded alongside the pointer through the epoch-fenced CAS.
319
515
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
320
- }) {
516
+ }): Promise<CreateSessionResponse> {
321
517
  const sessionMetadata = {
322
518
  ...input.metadata,
323
519
  model: input.model,
@@ -332,6 +528,9 @@ export async function createAndStartSession(input: {
332
528
  input.createIdempotencyKey,
333
529
  );
334
530
  if (existing) {
531
+ if (input.requestedSessionId && existing.id !== input.requestedSessionId) {
532
+ throw new SessionIdConflictError(input.requestedSessionId);
533
+ }
335
534
  return await finishStartSession(
336
535
  existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
337
536
  existing,
@@ -344,12 +543,18 @@ export async function createAndStartSession(input: {
344
543
  // advances the coalesced wake revision so an in-flight stale delivery can
345
544
  // never acknowledge work committed by the other caller.
346
545
  const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
546
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
347
547
  accountId: input.accountId,
348
548
  workspaceId: input.workspaceId,
349
549
  initialMessage: input.initialMessage,
550
+ initialTurnInstructions: input.turnInstructions ?? null,
350
551
  resources: input.resources,
351
552
  tools: input.tools,
553
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
352
554
  metadata: sessionMetadata,
555
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
556
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
557
+ createdByActor: input.createdByActor ?? null,
353
558
  model: input.model,
354
559
  sandboxBackend: input.sandboxBackend,
355
560
  variableSetId: input.variableSet?.id ?? null,
@@ -372,12 +577,18 @@ export async function createAndStartSession(input: {
372
577
  return await finishStartSession(input, keyed);
373
578
  }
374
579
  const session = await createSession(input.db, {
580
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
375
581
  accountId: input.accountId,
376
582
  workspaceId: input.workspaceId,
377
583
  initialMessage: input.initialMessage,
584
+ initialTurnInstructions: input.turnInstructions ?? null,
378
585
  resources: input.resources,
379
586
  tools: input.tools,
587
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
380
588
  metadata: sessionMetadata,
589
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
590
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
591
+ createdByActor: input.createdByActor ?? null,
381
592
  model: input.model,
382
593
  sandboxBackend: input.sandboxBackend,
383
594
  variableSetId: input.variableSet?.id ?? null,
@@ -405,11 +616,14 @@ async function finishStartSession(
405
616
  bus: EventBus;
406
617
  workflowClient: SessionWorkflowClient;
407
618
  initialMessage: string;
619
+ turnInstructions?: string | null;
408
620
  resources: ResourceRef[];
409
621
  tools: ToolRef[];
622
+ toolPolicy?: SessionToolPolicy;
410
623
  clientEventId?: string;
411
624
  model: string;
412
625
  reasoningEffort: Settings["openaiReasoningEffort"];
626
+ turnExecutionPolicy: TurnExecutionPolicyV1;
413
627
  sandboxBackend: Settings["sandboxBackend"];
414
628
  variableSet?: { id: string; name: string } | null;
415
629
  goal?: GoalSpec | null;
@@ -421,7 +635,7 @@ async function finishStartSession(
421
635
  } | null;
422
636
  },
423
637
  session: Session,
424
- ): Promise<Session> {
638
+ ): Promise<CreateSessionResponse> {
425
639
  // Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
426
640
  // the atomic initial turn transaction, so the FIRST turn routes to the chosen
427
641
  // machine. swapActiveSandbox does
@@ -460,7 +674,9 @@ async function finishStartSession(
460
674
  sessionId: session.id,
461
675
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
462
676
  reasoningEffortFallback: input.reasoningEffort,
677
+ turnExecutionPolicy: input.turnExecutionPolicy,
463
678
  createdEventPayload: {
679
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
464
680
  ...(input.variableSet
465
681
  ? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
466
682
  : {}),
@@ -488,7 +704,12 @@ async function finishStartSession(
488
704
  wakeRevision: started.workflowWakeRevision,
489
705
  });
490
706
  }
491
- return await requireSession(input.db, session.workspaceId, session.id);
707
+ const persisted = await requireSession(input.db, session.workspaceId, session.id);
708
+ const initialTurnId =
709
+ started.turn?.id ??
710
+ (await listSessionTurns(input.db, session.workspaceId, session.id, 1))[0]?.id ??
711
+ null;
712
+ return { ...persisted, initialTurnId };
492
713
  }
493
714
 
494
715
  export function workflowIdForSession(sessionId: string): string {
@@ -511,12 +732,16 @@ export function workflowIdForSession(sessionId: string): string {
511
732
  * later) and the MCP surfaces that share them validate identically and cannot
512
733
  * drift.
513
734
  */
514
- export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
735
+ export function canonicalConfiguredModel(
736
+ settings: Settings,
737
+ model: string | null | undefined,
738
+ ): string | null | undefined {
515
739
  if (model === null || model === undefined) {
516
- return;
740
+ return model;
517
741
  }
518
- if (configuredAllowedModels(settings).includes(model)) {
519
- return;
742
+ const canonicalModel = canonicalizeConfiguredModelId(settings, model);
743
+ if (configuredAllowedModels(settings).includes(canonicalModel)) {
744
+ return canonicalModel;
520
745
  }
521
746
  // Codex subscription models (codex/<slug>) are injected per-workspace by the
522
747
  // worker overlay at turn time, so they are never in the deployment-global
@@ -524,12 +749,16 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
524
749
  // only surfaces them for a connected workspace, and the worker enforces the
525
750
  // actual connection (an unconnected workspace fails the turn with a clear
526
751
  // "no Codex subscription connected" error rather than a misleading 422 here).
527
- if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
528
- return;
752
+ if (settings.codexSubscriptionEnabled && canonicalModel.startsWith(CODEX_MODEL_ID_PREFIX)) {
753
+ return canonicalModel;
529
754
  }
530
755
  throw new HTTPException(422, { message: `model is not available: ${model}` });
531
756
  }
532
757
 
758
+ export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
759
+ canonicalConfiguredModel(settings, model);
760
+ }
761
+
533
762
  /**
534
763
  * Reject a model the WORKSPACE's model policy blocks, at the same choke points
535
764
  * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
@@ -550,18 +779,25 @@ export async function assertWorkspaceModelPolicyAllows(
550
779
  if (model === null || model === undefined) {
551
780
  return;
552
781
  }
782
+ const canonicalModel = canonicalConfiguredModel(settings, model);
783
+ if (canonicalModel === null || canonicalModel === undefined) {
784
+ return;
785
+ }
553
786
  const policy = await getWorkspaceModelPolicy(db, workspaceId);
554
787
  if (!policy) {
555
788
  return;
556
789
  }
557
- const providerId = policyProviderIdForModel(settings, model);
558
- const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
790
+ const providerId = policyProviderIdForModel(settings, canonicalModel);
791
+ const verdict = evaluateWorkspaceModelPolicy(policy, {
792
+ providerId,
793
+ modelId: canonicalModel,
794
+ });
559
795
  if (!verdict.allowed) {
560
796
  throw new HTTPException(422, {
561
797
  message:
562
798
  verdict.reason === "provider"
563
- ? `model "${model}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
564
- : `model "${model}" is not allowed by this workspace's model policy`,
799
+ ? `model "${canonicalModel}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
800
+ : `model "${canonicalModel}" is not allowed by this workspace's model policy`,
565
801
  });
566
802
  }
567
803
  }
@@ -607,8 +843,10 @@ export async function postUserMessageTurn(input: {
607
843
  workspaceId: string;
608
844
  sessionId: string;
609
845
  text: string;
846
+ turnInstructions?: string | null;
610
847
  resources: ResourceRef[];
611
848
  tools: ToolRef[];
849
+ toolsProvided: boolean;
612
850
  model?: string | null;
613
851
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
614
852
  clientEventId?: string;
@@ -616,11 +854,15 @@ export async function postUserMessageTurn(input: {
616
854
  delivery?: "send" | "steer";
617
855
  origin?: "human" | "operator";
618
856
  actor?: string;
857
+ actorLabel?: string;
858
+ commandActor?: SessionCommandActor;
619
859
  controlEtag?: string | null;
620
860
  expectedDraftRevision?: number | null;
861
+ reasoningEffortFallback?: Settings["openaiReasoningEffort"];
862
+ turnExecutionPolicy: TurnExecutionPolicyV1;
621
863
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
622
864
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
623
- const requestedModel = input.model ?? null;
865
+ const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
624
866
  const requestedReasoningEffort = input.reasoningEffort ?? null;
625
867
  // Reject an explicit per-message model the host does not expose; an omitted
626
868
  // model inherits the session's model downstream (always a configured id).
@@ -636,17 +878,24 @@ export async function postUserMessageTurn(input: {
636
878
  workspaceId,
637
879
  sessionId,
638
880
  subjectId: input.actor ?? accountId,
639
- actor: { type: "human", subjectId: input.actor ?? accountId },
881
+ ...(input.actorLabel ? { subjectLabel: input.actorLabel } : {}),
882
+ actor: input.commandActor ?? {
883
+ type: "human",
884
+ subjectId: input.actor ?? accountId,
885
+ },
640
886
  operationKey,
641
887
  delivery: input.delivery ?? "send",
642
888
  controlEtag: input.controlEtag ?? null,
643
889
  expectedDraftRevision: input.expectedDraftRevision ?? null,
644
890
  text: input.text,
891
+ turnInstructions: input.turnInstructions ?? null,
645
892
  resources: input.resources,
646
893
  tools: input.tools,
894
+ toolsProvided: input.toolsProvided,
647
895
  model: requestedModel,
648
896
  reasoningEffort: requestedReasoningEffort,
649
- reasoningEffortFallback: settings.openaiReasoningEffort,
897
+ reasoningEffortFallback: input.reasoningEffortFallback ?? settings.openaiReasoningEffort,
898
+ turnExecutionPolicy: input.turnExecutionPolicy,
650
899
  source: input.origin === "operator" ? "api" : "user",
651
900
  mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
652
901
  }),
@@ -718,8 +967,10 @@ export async function postUserMessageTurn(input: {
718
967
  * Full create-session flow shared by `POST /sessions` and the first-party MCP
719
968
  * `session_create` tool: payload validation, resource/tool/variableSet
720
969
  * 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).
970
+ * the unparsed request body so absent-vs-empty execution-context fields keep
971
+ * their meaning: a child inherits omitted resources/tools/mcpServers from its
972
+ * trusted immediate parent, while explicit arrays (including []) win. A
973
+ * top-level create with omitted tools applies workspace-default capability MCPs.
723
974
  */
724
975
  export async function createSessionForRequest(
725
976
  deps: ApiRouteDeps,
@@ -729,32 +980,100 @@ export async function createSessionForRequest(
729
980
  ): Promise<Session> {
730
981
  const { settings, db, bus, workflowClient, objectStorage } = deps;
731
982
  const payload = CreateSessionRequest.parse(rawPayload);
983
+ // Parent linkage and execution-context inheritance come ONLY from the
984
+ // worker-signed sessionId claim. A caller cannot nominate a parent in the
985
+ // payload, so inheriting an existing repository/tool/credential snapshot does
986
+ // not turn sessions:create into arbitrary cross-session read authority.
987
+ const parentSessionId =
988
+ typeof grant.metadata?.["sessionId"] === "string"
989
+ ? (grant.metadata["sessionId"] as string)
990
+ : null;
991
+ if (parentSessionId) {
992
+ await requireSessionAuthorization(deps, grant, {
993
+ sessionId: parentSessionId,
994
+ operation: "session.child.create",
995
+ surface: "core",
996
+ });
997
+ }
998
+ const parentSession = parentSessionId ? await getSession(db, workspaceId, parentSessionId) : null;
999
+ if (parentSessionId && !parentSession) {
1000
+ throw new HTTPException(404, {
1001
+ message: `parent session not found in workspace: ${parentSessionId}`,
1002
+ });
1003
+ }
732
1004
  const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
733
1005
  db,
734
1006
  workspaceId,
735
1007
  settings,
736
1008
  );
737
- const sessionMcpServers = validateSessionMcpServersForCreate(
738
- capabilityRuntimeSettings,
739
- grant,
740
- payload.mcpServers,
741
- );
1009
+ const sessionMcpServers = hasOwnProperty(rawPayload, "mcpServers")
1010
+ ? validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers)
1011
+ : parentSession
1012
+ ? validateInheritedSessionMcpServersForCreate(
1013
+ await listSessionMcpServersForChildInheritance(db, workspaceId, parentSession.id),
1014
+ )
1015
+ : validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers);
742
1016
  const runtimeSettings = settingsWithSessionMcpServerConfigs(
743
1017
  capabilityRuntimeSettings,
744
1018
  sessionMcpServers.runtimeServers,
745
1019
  );
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);
1020
+ const resources = normalizeResources(
1021
+ hasOwnProperty(rawPayload, "resources")
1022
+ ? payload.resources
1023
+ : (parentSession?.resources ?? payload.resources),
1024
+ );
1025
+ const toolsProvided = hasOwnProperty(rawPayload, "tools");
1026
+ const requestedTools = validateToolRefs(
1027
+ toolsProvided ? payload.tools : (parentSession?.tools ?? payload.tools),
1028
+ runtimeSettings,
1029
+ );
1030
+ let selectedTools: ToolRef[];
1031
+ let toolPolicy: SessionToolPolicy;
1032
+ if (parentSession) {
1033
+ const parentTracksWorkspaceDefaults = parentSession.toolPolicy?.mode === "workspace_default";
1034
+ const parentEffective = withFirstPartyTools(
1035
+ parentTracksWorkspaceDefaults
1036
+ ? withDefaultEnabledCapabilityMcpTools(
1037
+ availableToolRefs(parentSession.tools, runtimeSettings),
1038
+ settings,
1039
+ runtimeSettings,
1040
+ )
1041
+ : parentSession.tools,
1042
+ runtimeSettings,
1043
+ );
1044
+ if (toolsProvided) {
1045
+ assertToolRefsSubset(
1046
+ requestedTools,
1047
+ parentEffective,
1048
+ "child tools may only narrow the parent session tool policy",
1049
+ );
1050
+ selectedTools = requestedTools;
1051
+ toolPolicy = { mode: "explicit", inheritedFromSessionId: parentSession.id };
1052
+ } else {
1053
+ selectedTools = parentEffective;
1054
+ toolPolicy = {
1055
+ mode: parentTracksWorkspaceDefaults ? "workspace_default" : "inherited",
1056
+ inheritedFromSessionId: parentSession.id,
1057
+ };
1058
+ }
1059
+ } else if (toolsProvided) {
1060
+ selectedTools = requestedTools;
1061
+ toolPolicy = { mode: "explicit", inheritedFromSessionId: null };
1062
+ } else {
1063
+ selectedTools = withDefaultEnabledCapabilityMcpTools(
1064
+ requestedTools,
1065
+ settings,
1066
+ capabilityRuntimeSettings,
1067
+ );
1068
+ toolPolicy = { mode: "workspace_default", inheritedFromSessionId: null };
1069
+ }
751
1070
  // The first-party MCP server is attached to EVERY session. It hosts the
752
1071
  // session's own metadata tool (set_session_title) + goal tools, and — only
753
1072
  // when the grant carries the permission — the orchestration/variableSet/
754
1073
  // github tools. Capability is gated per-tool by permission, never by whether
755
1074
  // the server is attached, so a bare chat still gets titling while the
756
1075
  // dangerous tools stay off by default.
757
- const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
1076
+ const tools = withFirstPartyTools(selectedTools, runtimeSettings);
758
1077
  await validateGitHubRepositorySelection(db, workspaceId, resources);
759
1078
  if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
760
1079
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -800,24 +1119,57 @@ export async function createSessionForRequest(
800
1119
  frozenRigVersionId = rig.activeVersion.id;
801
1120
  }
802
1121
  }
803
- assertConfiguredModel(settings, payload.model);
1122
+ const model = canonicalConfiguredModel(settings, payload.model ?? settings.openaiModel);
1123
+ if (model === null || model === undefined) {
1124
+ throw new Error("effective session model unexpectedly resolved to null");
1125
+ }
804
1126
  // Session creation persists the EFFECTIVE model — an omitted payload.model
805
1127
  // stamps the deployment default onto the session — so the policy must vet
806
1128
  // that effective value, not just explicit ones (a restricted workspace's
807
1129
  // default-model session would otherwise be born blocked).
808
- await assertWorkspaceModelPolicyAllows(
809
- db,
810
- settings,
811
- workspaceId,
812
- payload.model ?? settings.openaiModel,
813
- );
814
- const model = payload.model ?? settings.openaiModel;
1130
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
815
1131
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
1132
+ const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1133
+ modelId: model,
1134
+ requestedModelId: payload.model ?? null,
1135
+ modelSource: payload.model === undefined ? "deployment" : "explicit",
1136
+ reasoningEffort,
1137
+ reasoningSource: payload.reasoningEffort === undefined ? "deployment" : "explicit",
1138
+ });
1139
+ // Parent linkage was resolved above, before context validation. A child with
1140
+ // no explicit permission override inherits the creating session's effective
1141
+ // grant instead of silently expanding to standalone worker defaults.
816
1142
  // A session's first-party MCP token can carry a non-default permission set
817
1143
  // (how an operator hands a manager-style session the orchestration tools),
818
1144
  // but never one out-ranking its creator: every requested permission must be
819
- // held by the creating grant.
820
- let firstPartyMcpPermissions = payload.firstPartyMcpPermissions ?? null;
1145
+ // held by the creating grant. A top-level omission keeps the deployment's
1146
+ // normal worker defaults. A child omission inherits its creator's exact
1147
+ // effective grant, preserving a host/operator's narrowed capability boundary
1148
+ // through the whole session tree.
1149
+ const parentFirstPartyMcpPermissions = parentSession
1150
+ ? [...(parentSession.firstPartyMcpPermissions ?? DEFAULT_FIRST_PARTY_MCP_PERMISSIONS)]
1151
+ : null;
1152
+ if (
1153
+ parentFirstPartyMcpPermissions &&
1154
+ payload.firstPartyMcpPermissions?.some(
1155
+ (permission) => !hasPermission(parentFirstPartyMcpPermissions, permission),
1156
+ )
1157
+ ) {
1158
+ throw new HTTPException(403, {
1159
+ message: "child first-party MCP permissions may only narrow the parent session grant",
1160
+ });
1161
+ }
1162
+ // A worker-signed creator may itself carry less authority than its parent
1163
+ // session (for example a narrowly delegated spawn token). Inherit the
1164
+ // intersection in the shared canonical default order so null/default parent
1165
+ // policies cannot expand when runtime signing resolves them.
1166
+ let firstPartyMcpPermissions =
1167
+ payload.firstPartyMcpPermissions ??
1168
+ (parentFirstPartyMcpPermissions
1169
+ ? parentFirstPartyMcpPermissions.filter((permission) =>
1170
+ hasPermission(grant.permissions, permission),
1171
+ )
1172
+ : null);
821
1173
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
822
1174
  // An empty set would sign an unusable zero-permission token; the default
823
1175
  // worker set is expressed by omitting the field.
@@ -833,21 +1185,21 @@ export async function createSessionForRequest(
833
1185
  });
834
1186
  }
835
1187
  }
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.
1188
+ // A goal-bearing session with an explicit/effective permission set must
1189
+ // already carry goals:manage. Without it the worker cannot stop its own
1190
+ // continuation loop, but silently adding it would violate the child
1191
+ // authority contract: a child inherits or narrows its creator's exact grant
1192
+ // and never gains an unrequested permission. Top-level omission remains the
1193
+ // deployment's worker default, which includes the goal tools.
845
1194
  if (
846
1195
  payload.goal &&
847
1196
  firstPartyMcpPermissions &&
848
1197
  !firstPartyMcpPermissions.includes("goals:manage")
849
1198
  ) {
850
- firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
1199
+ throw new HTTPException(422, {
1200
+ message:
1201
+ "goal-bearing sessions require goals:manage in the resulting first-party MCP permission set",
1202
+ });
851
1203
  }
852
1204
  // Parent linkage: a worker is linked to its manager ONLY from the
853
1205
  // worker-signed sessionId claim on the creating grant — the manager
@@ -860,10 +1212,6 @@ export async function createSessionForRequest(
860
1212
  // its completion wake injects a user.message + queued turn into that session
861
1213
  // without holding sessions:control on it (a cross-session write escalation).
862
1214
  // 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
1215
  // Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
868
1216
  //
869
1217
  // The DEFAULT rule is context-dependent and resolved server-side from the
@@ -921,12 +1269,10 @@ export async function createSessionForRequest(
921
1269
  "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create.",
922
1270
  });
923
1271
  }
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
- });
1272
+ if (!parentSession) {
1273
+ throw new Error("trusted parent session was not resolved");
929
1274
  }
1275
+ const parent = parentSession;
930
1276
  const parentBoxed = parent.sandboxBackend !== "none";
931
1277
  const variableSetMismatch =
932
1278
  parentBoxed && !variableSetMatchesGroup(parent.variableSetId ?? null);
@@ -1074,53 +1420,74 @@ export async function createSessionForRequest(
1074
1420
  quantity: 1,
1075
1421
  model,
1076
1422
  });
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
- });
1423
+ const creationInitiator = creationInitiatorForGrant(grant);
1424
+ let session: CreateSessionResponse;
1425
+ try {
1426
+ session = await createAndStartSession({
1427
+ ...(payload.requestedSessionId ? { requestedSessionId: payload.requestedSessionId } : {}),
1428
+ db,
1429
+ bus,
1430
+ workflowClient,
1431
+ accountId: grant.accountId,
1432
+ workspaceId,
1433
+ initialMessage: payload.initialMessage,
1434
+ turnInstructions: payload.turnInstructions ?? null,
1435
+ resources,
1436
+ tools,
1437
+ toolPolicy,
1438
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1439
+ model,
1440
+ reasoningEffort,
1441
+ turnExecutionPolicy,
1442
+ // A shared spawn inherits the box's backend; a caller-supplied
1443
+ // sandboxBackend on a shared spawn is ignored (it is the same box). A
1444
+ // machine-targeted top-level create labels the home "selfhosted"
1445
+ // (machineHomeBackend), overriding the caller/deployment default so the row
1446
+ // matches where the session actually runs.
1447
+ sandboxBackend:
1448
+ inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
1449
+ // Mirror the backend relabel on the OS axis: only a machine-targeted
1450
+ // top-level create carries a derived OS; everything else is omitted and the
1451
+ // "linux" default holds (shared spawns keep the parent-box behavior).
1452
+ ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
1453
+ sandboxGroupId,
1454
+ metadata: payload.metadata,
1455
+ ...(creationInitiator.initiator ? { createdBy: creationInitiator.initiator } : {}),
1456
+ ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
1457
+ createdByActor: creationInitiator.actor ?? null,
1458
+ variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
1459
+ // Frozen rig binding (M3): both null for a rig-less session (today's path).
1460
+ rigId: frozenRigId,
1461
+ rigVersionId: frozenRigVersionId,
1462
+ goal: payload.goal ?? null,
1463
+ // Per-session persona instructions (already trimmed/validated by the
1464
+ // contracts schema). Persisted on the row; composed system-level at turn
1465
+ // time. Not surfaced as an event.
1466
+ instructions: payload.instructions ?? null,
1467
+ firstPartyMcpPermissions,
1468
+ mcpServers: sessionMcpServers.dbServers,
1469
+ sessionMcpServers: sessionMcpServers.metadata,
1470
+ parentSessionId,
1471
+ createIdempotencyKey: payload.idempotencyKey ?? null,
1472
+ // Create-time machine targeting (A-2a): when a target sandbox is named, the
1473
+ // active-sandbox pointer is seeded race-free inside createAndStartSession
1474
+ // (after the row exists, before the first turn dispatches). Validation
1475
+ // (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
1476
+ seedTargetSandbox: payload.targetSandboxId
1477
+ ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
1478
+ : null,
1479
+ });
1480
+ } catch (error) {
1481
+ if (error instanceof AgentCommandAuthorityError) {
1482
+ throw new HTTPException(403, { message: error.message });
1483
+ }
1484
+ if (error instanceof SessionIdConflictError) {
1485
+ throw new HTTPException(409, {
1486
+ message: "requested session id is already in use",
1487
+ });
1488
+ }
1489
+ throw error;
1490
+ }
1124
1491
  await recordWorkspaceUsage(deps, {
1125
1492
  accountId: grant.accountId,
1126
1493
  workspaceId,
@@ -1130,6 +1497,10 @@ export async function createSessionForRequest(
1130
1497
  unit: "run",
1131
1498
  sourceResourceType: "session",
1132
1499
  sourceResourceId: session.id,
1500
+ sessionId: session.id,
1501
+ initiator: session.createdBy,
1502
+ initiatorContext: session.createdByContext,
1503
+ origin: creationInitiator.actor ? "system" : "user",
1133
1504
  idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`,
1134
1505
  });
1135
1506
  return session;
@@ -1139,8 +1510,9 @@ export async function createSessionForRequest(
1139
1510
  * Full accept-user-message flow shared by the `user.message` branch of
1140
1511
  * `POST /sessions/:id/events` and the first-party MCP `session_send_message`
1141
1512
  * tool: resource/tool validation, usage limits, the locked append + turn
1142
- * enqueue, and usage recording. `toolsProvided: false` applies the
1143
- * workspace's default capability MCP tools, matching an absent `tools` key.
1513
+ * enqueue, and usage recording. `toolsProvided: false` durably preserves an
1514
+ * absent `tools` key so execution inherits the session policy; an explicit
1515
+ * empty array is a deliberate per-turn narrowing.
1144
1516
  */
1145
1517
  export async function acceptSessionUserMessage(
1146
1518
  deps: AcceptSessionUserMessageDependencies,
@@ -1149,6 +1521,7 @@ export async function acceptSessionUserMessage(
1149
1521
  sessionId: string,
1150
1522
  input: {
1151
1523
  text: string;
1524
+ turnInstructions?: string | null;
1152
1525
  resources?: ResourceRef[];
1153
1526
  tools?: ToolRef[];
1154
1527
  toolsProvided: boolean;
@@ -1162,7 +1535,18 @@ export async function acceptSessionUserMessage(
1162
1535
  expectedDraftRevision?: number | null;
1163
1536
  },
1164
1537
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1538
+ if (input.toolsProvided && !deps.settings.sessionTurnToolReplacementEnabled) {
1539
+ throw new HTTPException(503, {
1540
+ message:
1541
+ "explicit follow-up tool replacement is temporarily unavailable until provenance-aware turn workers finish rolling out; omit tools to inherit the session policy and retry",
1542
+ });
1543
+ }
1165
1544
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1545
+ await requireSessionAuthorization(deps, grant, {
1546
+ sessionId,
1547
+ operation: input.delivery === "steer" ? "session.steer" : "session.append",
1548
+ surface: "core",
1549
+ });
1166
1550
  const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1167
1551
  db,
1168
1552
  workspaceId,
@@ -1172,21 +1556,55 @@ export async function acceptSessionUserMessage(
1172
1556
  // turn's effective model (a follow-up turn inherits the session's model). A
1173
1557
  // pure read with no side effects.
1174
1558
  const existingSession = await requireSession(db, workspaceId, sessionId);
1559
+ const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
1560
+ const effectiveModel =
1561
+ canonicalConfiguredModel(settings, requestedModel ?? existingSession.model) ?? null;
1562
+ if (effectiveModel === null) {
1563
+ throw new Error("effective follow-up model unexpectedly resolved to null");
1564
+ }
1565
+ const sessionReasoningEffort = reasoningEffortForSession(
1566
+ existingSession.metadata,
1567
+ settings.openaiReasoningEffort,
1568
+ );
1569
+ const effectiveReasoningEffort = input.reasoningEffort ?? sessionReasoningEffort;
1570
+ const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1571
+ modelId: effectiveModel,
1572
+ requestedModelId: input.model ?? null,
1573
+ modelSource: input.model == null ? "session" : "explicit",
1574
+ reasoningEffort: effectiveReasoningEffort,
1575
+ reasoningSource: input.reasoningEffort == null ? "session" : "explicit",
1576
+ });
1175
1577
  const runtimeSettings = settingsWithSessionMcpServerMetadata(
1176
1578
  capabilityRuntimeSettings,
1177
1579
  existingSession.mcpServers,
1178
1580
  );
1179
1581
  const requestedResources = normalizeResources(input.resources ?? []);
1180
- const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
1181
- const requestedTools = input.toolsProvided
1182
- ? validatedTools
1183
- : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, capabilityRuntimeSettings);
1582
+ const tracksWorkspaceDefaults = existingSession.toolPolicy?.mode === "workspace_default";
1583
+ const sessionPolicyTools = withFirstPartyTools(
1584
+ tracksWorkspaceDefaults
1585
+ ? withDefaultEnabledCapabilityMcpTools(
1586
+ availableToolRefs(existingSession.tools, runtimeSettings),
1587
+ settings,
1588
+ capabilityRuntimeSettings,
1589
+ )
1590
+ : existingSession.tools,
1591
+ runtimeSettings,
1592
+ );
1593
+ const validatedTools = input.toolsProvided
1594
+ ? validateToolRefsForSessionPolicy({
1595
+ requested: input.tools ?? [],
1596
+ settings: runtimeSettings,
1597
+ allowedTools: sessionPolicyTools,
1598
+ message: "message tools may only narrow the session tool policy",
1599
+ })
1600
+ : [];
1601
+ const requestedTools = input.toolsProvided ? validatedTools : [];
1184
1602
  await requireLimit(deps, {
1185
1603
  accountId: grant.accountId,
1186
1604
  workspaceId,
1187
1605
  action: "agent_run:create",
1188
1606
  quantity: 1,
1189
- model: input.model ?? existingSession.model,
1607
+ model: effectiveModel,
1190
1608
  });
1191
1609
  if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
1192
1610
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -1202,6 +1620,7 @@ export async function acceptSessionUserMessage(
1202
1620
  session: existingSession,
1203
1621
  updates: input.mcpCredentialUpdates ?? [],
1204
1622
  });
1623
+ const delegatedServiceInitiator = serviceInitiatorForGrant(grant);
1205
1624
  const { accepted, turn } = await postUserMessageTurn({
1206
1625
  db,
1207
1626
  bus,
@@ -1211,14 +1630,31 @@ export async function acceptSessionUserMessage(
1211
1630
  workspaceId,
1212
1631
  sessionId,
1213
1632
  text: input.text,
1633
+ turnInstructions: input.turnInstructions ?? null,
1214
1634
  resources: requestedResources,
1215
1635
  tools: requestedTools,
1636
+ toolsProvided: input.toolsProvided,
1216
1637
  model: input.model ?? null,
1217
1638
  reasoningEffort: input.reasoningEffort ?? null,
1639
+ reasoningEffortFallback: sessionReasoningEffort,
1640
+ turnExecutionPolicy,
1218
1641
  mcpCredentialUpdates,
1219
1642
  delivery: input.delivery ?? "send",
1220
- origin: input.origin ?? "human",
1643
+ origin: delegatedServiceInitiator ? "operator" : (input.origin ?? "human"),
1221
1644
  actor: grant.subjectId,
1645
+ ...(grant.subjectLabel ? { actorLabel: grant.subjectLabel } : {}),
1646
+ ...(delegatedServiceInitiator
1647
+ ? {
1648
+ commandActor: {
1649
+ type: "service" as const,
1650
+ subjectId: delegatedServiceInitiator.initiator.subjectId,
1651
+ ...(delegatedServiceInitiator.initiator.label
1652
+ ? { subjectLabel: delegatedServiceInitiator.initiator.label }
1653
+ : {}),
1654
+ context: delegatedServiceInitiator.context,
1655
+ },
1656
+ }
1657
+ : {}),
1222
1658
  ...(input.controlEtag !== undefined ? { controlEtag: input.controlEtag } : {}),
1223
1659
  ...(input.expectedDraftRevision !== undefined
1224
1660
  ? { expectedDraftRevision: input.expectedDraftRevision }
@@ -1234,6 +1670,11 @@ export async function acceptSessionUserMessage(
1234
1670
  unit: "run",
1235
1671
  sourceResourceType: "session_turn",
1236
1672
  sourceResourceId: turn.id,
1673
+ sessionId,
1674
+ turnId: turn.id,
1675
+ initiator: turn.initiator,
1676
+ initiatorContext: turn.initiatorContext,
1677
+ origin: turn.source,
1237
1678
  idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`,
1238
1679
  });
1239
1680
  return { accepted, turn };
@@ -1249,13 +1690,27 @@ export async function acceptSessionUserMessage(
1249
1690
  * happened so callers can avoid double work.
1250
1691
  */
1251
1692
  export async function updateSessionTitle(
1252
- deps: { db: Database; bus: EventBus },
1253
- workspaceId: string,
1693
+ deps: {
1694
+ db: Database;
1695
+ bus: EventBus;
1696
+ sessionAuthorization?: SessionAuthorizationPort | null;
1697
+ },
1698
+ grant: AccessGrant,
1254
1699
  sessionId: string,
1255
1700
  title: string,
1256
1701
  source: "user" | "agent",
1257
- ): Promise<{ updated: boolean; title: string | null }> {
1702
+ ): Promise<{
1703
+ updated: boolean;
1704
+ title: string | null;
1705
+ relatedSessionAccess: "target" | "root";
1706
+ }> {
1258
1707
  const { db, bus } = deps;
1708
+ const authorization = await requireSessionAuthorization(deps, grant, {
1709
+ sessionId,
1710
+ operation: "session.title.write",
1711
+ surface: "core",
1712
+ });
1713
+ const workspaceId = grant.workspaceId;
1259
1714
  const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
1260
1715
  if (result.updated) {
1261
1716
  await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
@@ -1268,11 +1723,92 @@ export async function updateSessionTitle(
1268
1723
  },
1269
1724
  ]);
1270
1725
  }
1271
- return result;
1726
+ return {
1727
+ ...result,
1728
+ relatedSessionAccess: authorization?.relatedSessionAccess ?? "root",
1729
+ };
1272
1730
  }
1273
1731
 
1274
- export async function readSessionLineage(db: Database, workspaceId: string, sessionId: string) {
1275
- const lineage = await getSessionLineage(db, workspaceId, sessionId);
1732
+ /**
1733
+ * Update one existing session MCP server's approval policy. The database
1734
+ * serializes this write with attempt claim under the session lock: an already
1735
+ * claimed attempt retains its immutable snapshot, while the next claim captures
1736
+ * this value. No attempt is cancelled, restarted, or reinterpreted.
1737
+ */
1738
+ export async function updateSessionMcpApprovalPolicy(
1739
+ deps: {
1740
+ db: Database;
1741
+ bus: EventBus;
1742
+ sessionAuthorization?: SessionAuthorizationPort | null;
1743
+ },
1744
+ grant: AccessGrant,
1745
+ sessionId: string,
1746
+ serverId: string,
1747
+ requireApproval: SessionMcpApprovalPolicy,
1748
+ ): Promise<UpdateSessionMcpApprovalPolicyResponse> {
1749
+ const normalizedPolicy = SessionMcpApprovalPolicy.parse(requireApproval);
1750
+ await requireSessionAuthorization(deps, grant, {
1751
+ sessionId,
1752
+ operation: "session.mcp.approval_policy.write",
1753
+ surface: "core",
1754
+ });
1755
+ requirePermission(grant, "sessions:control");
1756
+
1757
+ const outcome: { server?: SessionMcpServerMetadata } = {};
1758
+ const events = await appendSessionEventsWithLockedSessionUpdate(
1759
+ deps.db,
1760
+ grant.workspaceId,
1761
+ sessionId,
1762
+ async (_session, context) => {
1763
+ const result = await context.updateSessionMcpApprovalPolicy(serverId, normalizedPolicy);
1764
+ if (!result.server) {
1765
+ throw new HTTPException(404, { message: "session MCP server not found" });
1766
+ }
1767
+ outcome.server = result.server;
1768
+ return {
1769
+ events: result.changed
1770
+ ? [
1771
+ {
1772
+ type: "session.mcp.approval_policy.updated" as const,
1773
+ payload: {
1774
+ serverId,
1775
+ effectiveFrom: "next_attempt",
1776
+ },
1777
+ },
1778
+ ]
1779
+ : [],
1780
+ };
1781
+ },
1782
+ );
1783
+ const updatedServer = outcome.server;
1784
+ if (!updatedServer) {
1785
+ throw new Error("session MCP approval policy update returned no server");
1786
+ }
1787
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
1788
+ return {
1789
+ server: updatedServer,
1790
+ effectiveFrom: "next_attempt",
1791
+ };
1792
+ }
1793
+
1794
+ export async function readSessionLineage(
1795
+ deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1796
+ grant: AccessGrant,
1797
+ sessionId: string,
1798
+ ) {
1799
+ const authorization = await requireSessionAuthorization(deps, grant, {
1800
+ sessionId,
1801
+ operation: "session.lineage.read",
1802
+ surface: "core",
1803
+ });
1804
+ if (authorization?.relatedSessionAccess === "target") {
1805
+ const session = await getSession(deps.db, grant.workspaceId, sessionId);
1806
+ if (!session) {
1807
+ throw new HTTPException(404, { message: "session not found" });
1808
+ }
1809
+ return { ancestors: [], children: [], truncated: false };
1810
+ }
1811
+ const lineage = await getSessionLineage(deps.db, grant.workspaceId, sessionId);
1276
1812
  if (!lineage) {
1277
1813
  throw new HTTPException(404, { message: "session not found" });
1278
1814
  }