@opengeni/core 0.8.0 → 0.11.1

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,7 +1,15 @@
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
+ SessionSpawnDenial,
5
13
  ServiceTurnInitiator,
6
14
  ServiceTurnInitiatorContext,
7
15
  evaluateWorkspaceModelPolicy,
@@ -14,18 +22,22 @@ import {
14
22
  type ResourceRef,
15
23
  type Session,
16
24
  type SessionEvent,
25
+ SessionMcpApprovalPolicy,
17
26
  type SessionMcpCredentialUpdateInput,
18
27
  type SessionMcpServerInput,
19
28
  type SessionMcpServerMetadata,
29
+ type UpdateSessionMcpApprovalPolicyResponse,
20
30
  type SessionAuthorizationPort,
31
+ type SessionToolPolicy,
21
32
  type SessionTurn,
22
33
  type ToolRef,
23
34
  type TurnInitiator,
24
35
  type TurnInitiatorContext,
36
+ type TurnExecutionPolicyV1,
25
37
  } from "@opengeni/contracts";
26
38
  import {
27
39
  createSession,
28
- createSessionWithIdempotencyKey,
40
+ createSessionWithIdempotencyKeyResult,
29
41
  encryptVariableSetValue,
30
42
  getAnySessionInGroup,
31
43
  getEnrollment,
@@ -36,7 +48,7 @@ import {
36
48
  getSandbox,
37
49
  getSession,
38
50
  SessionIdConflictError,
39
- getSessionByCreateIdempotencyKey,
51
+ getSessionSpawnDenialByIdempotencyKey,
40
52
  getSessionEvent,
41
53
  getWorkspaceControlEvent,
42
54
  getSessionLineage,
@@ -47,6 +59,7 @@ import {
47
59
  listSessionMcpServersForChildInheritance,
48
60
  requireSession,
49
61
  submitHumanPromptInTransaction,
62
+ appendSessionEventsWithLockedSessionUpdate,
50
63
  updateSessionTitle as updateSessionTitleRow,
51
64
  withWorkspaceSubjectRls,
52
65
  type CreateSessionMcpServerInput,
@@ -54,6 +67,7 @@ import {
54
67
  type UpdateSessionMcpServerCredentialsInput,
55
68
  QueueCommandConflictError,
56
69
  AgentCommandAuthorityError,
70
+ SessionSpawnDeniedDbError,
57
71
  SessionControlConflictError,
58
72
  type SessionCommandActor,
59
73
  } from "@opengeni/db";
@@ -76,11 +90,14 @@ import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
76
90
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
77
91
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
78
92
  import {
93
+ assertToolRefsSubset,
94
+ availableToolRefs,
79
95
  mergeToolRefs,
80
96
  normalizeResources,
81
97
  validateFileResources,
82
98
  validateGitHubRepositorySelection,
83
99
  validateToolRefs,
100
+ validateToolRefsForSessionPolicy,
84
101
  withDefaultEnabledCapabilityMcpTools,
85
102
  } from "./resources";
86
103
 
@@ -90,6 +107,34 @@ const maxSessionMcpCredentialHeaderValueLength = 4096;
90
107
  // RFC 9110 field-name token characters.
91
108
  const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
92
109
 
110
+ /** Transport-neutral typed denial raised only after its audit row committed. */
111
+ export class SessionSpawnDeniedError extends Error {
112
+ readonly denial: SessionSpawnDenial;
113
+
114
+ constructor(denial: SessionSpawnDenial) {
115
+ super(sessionSpawnDeniedMessage(denial));
116
+ this.name = "SessionSpawnDeniedError";
117
+ this.denial = denial;
118
+ }
119
+ }
120
+
121
+ function sessionSpawnDeniedMessage(denial: SessionSpawnDenial): string {
122
+ if (denial.code === "nested_agent_depth_override_forbidden") {
123
+ return `requested nested-agent depth limit ${denial.requestedMaxNestedAgentDepthOverride ?? "unknown"} exceeds inherited limit ${denial.effectiveMaxNestedAgentDepth}; workspace:admin is required to increase it`;
124
+ }
125
+ return `nested-agent depth ${denial.attemptedDepth} exceeds effective limit ${denial.effectiveMaxNestedAgentDepth} (current parent depth ${denial.currentDepth})`;
126
+ }
127
+
128
+ export function sessionSpawnDenialEnvelope(error: SessionSpawnDeniedError) {
129
+ return {
130
+ error: {
131
+ code: error.denial.code,
132
+ message: error.message,
133
+ details: { denial: error.denial },
134
+ },
135
+ } as const;
136
+ }
137
+
93
138
  type ValidatedSessionMcpServers = {
94
139
  runtimeServers: Settings["mcpServers"];
95
140
  dbServers: CreateSessionMcpServerInput[];
@@ -265,6 +310,7 @@ function mcpServerConfigFromMetadata(
265
310
  ...(server.name ? { name: server.name } : {}),
266
311
  url: server.url,
267
312
  cacheToolsList: false,
313
+ requireApproval: server.requireApproval,
268
314
  ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
269
315
  };
270
316
  }
@@ -340,6 +386,7 @@ function validateSessionMcpServersForCreate(
340
386
  url: server.url,
341
387
  headerNames: Object.keys(headersEncrypted).sort(),
342
388
  credentialVersion: 1,
389
+ requireApproval: server.requireApproval ?? false,
343
390
  connectionRef: server.connectionRef ?? null,
344
391
  });
345
392
  }
@@ -383,6 +430,7 @@ function validateInheritedSessionMcpServersForCreate(
383
430
  url: server.url,
384
431
  headerNames: Object.keys(server.headersEncrypted ?? {}).sort(),
385
432
  credentialVersion: 1,
433
+ requireApproval: server.requireApproval ?? false,
386
434
  connectionRef: server.connectionRef ?? null,
387
435
  })),
388
436
  };
@@ -436,9 +484,14 @@ export async function createAndStartSession(input: {
436
484
  turnInstructions?: string | null;
437
485
  resources: ResourceRef[];
438
486
  tools: ToolRef[];
487
+ // Public admission always supplies provenance; optional keeps internal
488
+ // callers that predate durable tool-policy provenance source-compatible
489
+ // during the rolling deploy.
490
+ toolPolicy?: SessionToolPolicy;
439
491
  clientEventId?: string;
440
492
  model: string;
441
493
  reasoningEffort: Settings["openaiReasoningEffort"];
494
+ turnExecutionPolicy: TurnExecutionPolicyV1;
442
495
  sandboxBackend: Settings["sandboxBackend"];
443
496
  metadata: Record<string, unknown>;
444
497
  createdBy?: TurnInitiator;
@@ -490,36 +543,27 @@ export async function createAndStartSession(input: {
490
543
  // `workingDir` (optional) is the path/cwd base the chosen machine runs under,
491
544
  // seeded alongside the pointer through the epoch-fenced CAS.
492
545
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
546
+ // Exact actor-private pre-session draft represented by this create. The
547
+ // initializer consumes it only after the first durable runnable unit commits.
548
+ consumeNewSessionDraft?: { subjectId: string; expectedRevision: number } | null;
549
+ // A child may lower its inherited nested-agent depth limit freely; increases
550
+ // are authorized by the caller's workspace:admin grant and checked again by
551
+ // the database admission transaction.
552
+ maxNestedAgentDepthOverride?: number | null;
553
+ allowNestedAgentDepthIncrease?: boolean;
554
+ subjectId?: string | null;
493
555
  }): Promise<CreateSessionResponse> {
494
556
  const sessionMetadata = {
495
557
  ...input.metadata,
496
558
  model: input.model,
497
559
  reasoningEffort: input.reasoningEffort,
498
560
  };
499
- // Fast path with a key: return a session already created under this key
500
- // (the sequential retry / double-submit case) without inserting again.
561
+ // Keyed creation is intentionally handled only by the database admission
562
+ // transaction below. Its workspace/key lock replays either the successful
563
+ // session or the committed denial atomically; an application-side lookup
564
+ // cannot serialize those two source tables against an older writer.
501
565
  if (input.createIdempotencyKey) {
502
- const existing = await getSessionByCreateIdempotencyKey(
503
- input.db,
504
- input.workspaceId,
505
- input.createIdempotencyKey,
506
- );
507
- if (existing) {
508
- if (input.requestedSessionId && existing.id !== input.requestedSessionId) {
509
- throw new SessionIdConflictError(input.requestedSessionId);
510
- }
511
- return await finishStartSession(
512
- existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
513
- existing,
514
- );
515
- }
516
- // No prior session: insert under the key, racing concurrent creates. The
517
- // partial unique index lets exactly one insert win; a loser gets back the
518
- // winner's row with created=false. Both callers may enter the idempotent
519
- // initializer; exactly one creates the first events/turn. Each retry
520
- // advances the coalesced wake revision so an in-flight stale delivery can
521
- // never acknowledge work committed by the other caller.
522
- const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
566
+ const keyedResult = await createSessionWithIdempotencyKeyResult(input.db, {
523
567
  ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
524
568
  accountId: input.accountId,
525
569
  workspaceId: input.workspaceId,
@@ -527,6 +571,7 @@ export async function createAndStartSession(input: {
527
571
  initialTurnInstructions: input.turnInstructions ?? null,
528
572
  resources: input.resources,
529
573
  tools: input.tools,
574
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
530
575
  metadata: sessionMetadata,
531
576
  ...(input.createdBy ? { createdBy: input.createdBy } : {}),
532
577
  ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
@@ -543,7 +588,14 @@ export async function createAndStartSession(input: {
543
588
  sandboxGroupId: input.sandboxGroupId ?? null,
544
589
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
545
590
  mcpServers: input.mcpServers ?? [],
591
+ maxNestedAgentDepthOverride: input.maxNestedAgentDepthOverride ?? null,
592
+ allowNestedAgentDepthIncrease: input.allowNestedAgentDepthIncrease ?? false,
593
+ subjectId: input.subjectId ?? null,
546
594
  });
595
+ if (keyedResult.denied) {
596
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(keyedResult.denial));
597
+ }
598
+ const { session: keyed, created } = keyedResult;
547
599
  if (!created) {
548
600
  return await finishStartSession(
549
601
  keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
@@ -552,30 +604,42 @@ export async function createAndStartSession(input: {
552
604
  }
553
605
  return await finishStartSession(input, keyed);
554
606
  }
555
- const session = await createSession(input.db, {
556
- ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
557
- accountId: input.accountId,
558
- workspaceId: input.workspaceId,
559
- initialMessage: input.initialMessage,
560
- initialTurnInstructions: input.turnInstructions ?? null,
561
- resources: input.resources,
562
- tools: input.tools,
563
- metadata: sessionMetadata,
564
- ...(input.createdBy ? { createdBy: input.createdBy } : {}),
565
- ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
566
- createdByActor: input.createdByActor ?? null,
567
- model: input.model,
568
- sandboxBackend: input.sandboxBackend,
569
- variableSetId: input.variableSet?.id ?? null,
570
- rigId: input.rigId ?? null,
571
- rigVersionId: input.rigVersionId ?? null,
572
- firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
573
- instructions: input.instructions ?? null,
574
- parentSessionId: input.parentSessionId ?? null,
575
- sandboxGroupId: input.sandboxGroupId ?? null,
576
- ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
577
- mcpServers: input.mcpServers ?? [],
578
- });
607
+ let session: Session;
608
+ try {
609
+ session = await createSession(input.db, {
610
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
611
+ accountId: input.accountId,
612
+ workspaceId: input.workspaceId,
613
+ initialMessage: input.initialMessage,
614
+ initialTurnInstructions: input.turnInstructions ?? null,
615
+ resources: input.resources,
616
+ tools: input.tools,
617
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
618
+ metadata: sessionMetadata,
619
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
620
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
621
+ createdByActor: input.createdByActor ?? null,
622
+ model: input.model,
623
+ sandboxBackend: input.sandboxBackend,
624
+ variableSetId: input.variableSet?.id ?? null,
625
+ rigId: input.rigId ?? null,
626
+ rigVersionId: input.rigVersionId ?? null,
627
+ firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
628
+ instructions: input.instructions ?? null,
629
+ parentSessionId: input.parentSessionId ?? null,
630
+ sandboxGroupId: input.sandboxGroupId ?? null,
631
+ ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
632
+ mcpServers: input.mcpServers ?? [],
633
+ maxNestedAgentDepthOverride: input.maxNestedAgentDepthOverride ?? null,
634
+ allowNestedAgentDepthIncrease: input.allowNestedAgentDepthIncrease ?? false,
635
+ subjectId: input.subjectId ?? null,
636
+ });
637
+ } catch (error) {
638
+ if (error instanceof SessionSpawnDeniedDbError) {
639
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(error.denial));
640
+ }
641
+ throw error;
642
+ }
579
643
  return await finishStartSession(input, session);
580
644
  }
581
645
 
@@ -594,9 +658,11 @@ async function finishStartSession(
594
658
  turnInstructions?: string | null;
595
659
  resources: ResourceRef[];
596
660
  tools: ToolRef[];
661
+ toolPolicy?: SessionToolPolicy;
597
662
  clientEventId?: string;
598
663
  model: string;
599
664
  reasoningEffort: Settings["openaiReasoningEffort"];
665
+ turnExecutionPolicy: TurnExecutionPolicyV1;
600
666
  sandboxBackend: Settings["sandboxBackend"];
601
667
  variableSet?: { id: string; name: string } | null;
602
668
  goal?: GoalSpec | null;
@@ -606,6 +672,7 @@ async function finishStartSession(
606
672
  settings: Settings;
607
673
  workingDir?: string | null;
608
674
  } | null;
675
+ consumeNewSessionDraft?: { subjectId: string; expectedRevision: number } | null;
609
676
  },
610
677
  session: Session,
611
678
  ): Promise<CreateSessionResponse> {
@@ -647,7 +714,9 @@ async function finishStartSession(
647
714
  sessionId: session.id,
648
715
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
649
716
  reasoningEffortFallback: input.reasoningEffort,
717
+ turnExecutionPolicy: input.turnExecutionPolicy,
650
718
  createdEventPayload: {
719
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
651
720
  ...(input.variableSet
652
721
  ? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
653
722
  : {}),
@@ -664,6 +733,7 @@ async function finishStartSession(
664
733
  : {}),
665
734
  }
666
735
  : null,
736
+ consumeNewSessionDraft: input.consumeNewSessionDraft ?? null,
667
737
  });
668
738
  await publishDurableSessionEvents(input.bus, session.workspaceId, session.id, started.events);
669
739
  if (started.workflowWakeRevision !== null) {
@@ -703,12 +773,16 @@ export function workflowIdForSession(sessionId: string): string {
703
773
  * later) and the MCP surfaces that share them validate identically and cannot
704
774
  * drift.
705
775
  */
706
- export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
776
+ export function canonicalConfiguredModel(
777
+ settings: Settings,
778
+ model: string | null | undefined,
779
+ ): string | null | undefined {
707
780
  if (model === null || model === undefined) {
708
- return;
781
+ return model;
709
782
  }
710
- if (configuredAllowedModels(settings).includes(model)) {
711
- return;
783
+ const canonicalModel = canonicalizeConfiguredModelId(settings, model);
784
+ if (configuredAllowedModels(settings).includes(canonicalModel)) {
785
+ return canonicalModel;
712
786
  }
713
787
  // Codex subscription models (codex/<slug>) are injected per-workspace by the
714
788
  // worker overlay at turn time, so they are never in the deployment-global
@@ -716,12 +790,16 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
716
790
  // only surfaces them for a connected workspace, and the worker enforces the
717
791
  // actual connection (an unconnected workspace fails the turn with a clear
718
792
  // "no Codex subscription connected" error rather than a misleading 422 here).
719
- if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
720
- return;
793
+ if (settings.codexSubscriptionEnabled && canonicalModel.startsWith(CODEX_MODEL_ID_PREFIX)) {
794
+ return canonicalModel;
721
795
  }
722
796
  throw new HTTPException(422, { message: `model is not available: ${model}` });
723
797
  }
724
798
 
799
+ export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
800
+ canonicalConfiguredModel(settings, model);
801
+ }
802
+
725
803
  /**
726
804
  * Reject a model the WORKSPACE's model policy blocks, at the same choke points
727
805
  * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
@@ -742,18 +820,25 @@ export async function assertWorkspaceModelPolicyAllows(
742
820
  if (model === null || model === undefined) {
743
821
  return;
744
822
  }
823
+ const canonicalModel = canonicalConfiguredModel(settings, model);
824
+ if (canonicalModel === null || canonicalModel === undefined) {
825
+ return;
826
+ }
745
827
  const policy = await getWorkspaceModelPolicy(db, workspaceId);
746
828
  if (!policy) {
747
829
  return;
748
830
  }
749
- const providerId = policyProviderIdForModel(settings, model);
750
- const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
831
+ const providerId = policyProviderIdForModel(settings, canonicalModel);
832
+ const verdict = evaluateWorkspaceModelPolicy(policy, {
833
+ providerId,
834
+ modelId: canonicalModel,
835
+ });
751
836
  if (!verdict.allowed) {
752
837
  throw new HTTPException(422, {
753
838
  message:
754
839
  verdict.reason === "provider"
755
- ? `model "${model}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
756
- : `model "${model}" is not allowed by this workspace's model policy`,
840
+ ? `model "${canonicalModel}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
841
+ : `model "${canonicalModel}" is not allowed by this workspace's model policy`,
757
842
  });
758
843
  }
759
844
  }
@@ -802,6 +887,7 @@ export async function postUserMessageTurn(input: {
802
887
  turnInstructions?: string | null;
803
888
  resources: ResourceRef[];
804
889
  tools: ToolRef[];
890
+ toolsProvided: boolean;
805
891
  model?: string | null;
806
892
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
807
893
  clientEventId?: string;
@@ -813,9 +899,11 @@ export async function postUserMessageTurn(input: {
813
899
  commandActor?: SessionCommandActor;
814
900
  controlEtag?: string | null;
815
901
  expectedDraftRevision?: number | null;
902
+ reasoningEffortFallback?: Settings["openaiReasoningEffort"];
903
+ turnExecutionPolicy: TurnExecutionPolicyV1;
816
904
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
817
905
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
818
- const requestedModel = input.model ?? null;
906
+ const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
819
907
  const requestedReasoningEffort = input.reasoningEffort ?? null;
820
908
  // Reject an explicit per-message model the host does not expose; an omitted
821
909
  // model inherits the session's model downstream (always a configured id).
@@ -844,9 +932,11 @@ export async function postUserMessageTurn(input: {
844
932
  turnInstructions: input.turnInstructions ?? null,
845
933
  resources: input.resources,
846
934
  tools: input.tools,
935
+ toolsProvided: input.toolsProvided,
847
936
  model: requestedModel,
848
937
  reasoningEffort: requestedReasoningEffort,
849
- reasoningEffortFallback: settings.openaiReasoningEffort,
938
+ reasoningEffortFallback: input.reasoningEffortFallback ?? settings.openaiReasoningEffort,
939
+ turnExecutionPolicy: input.turnExecutionPolicy,
850
940
  source: input.origin === "operator" ? "api" : "user",
851
941
  mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
852
942
  }),
@@ -931,6 +1021,20 @@ export async function createSessionForRequest(
931
1021
  ): Promise<Session> {
932
1022
  const { settings, db, bus, workflowClient, objectStorage } = deps;
933
1023
  const payload = CreateSessionRequest.parse(rawPayload);
1024
+ // A committed keyed denial is the idempotent outcome even if mutable
1025
+ // resources, policy, authorization, or budget have changed since the first
1026
+ // attempt. Replay it before any of those checks, just as a keyed successful
1027
+ // session is returned rather than recreated later in createAndStartSession.
1028
+ if (payload.idempotencyKey) {
1029
+ const denial = await getSessionSpawnDenialByIdempotencyKey(
1030
+ db,
1031
+ workspaceId,
1032
+ payload.idempotencyKey,
1033
+ );
1034
+ if (denial) {
1035
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(denial));
1036
+ }
1037
+ }
934
1038
  // Parent linkage and execution-context inheritance come ONLY from the
935
1039
  // worker-signed sessionId claim. A caller cannot nominate a parent in the
936
1040
  // payload, so inheriting an existing repository/tool/credential snapshot does
@@ -973,21 +1077,58 @@ export async function createSessionForRequest(
973
1077
  ? payload.resources
974
1078
  : (parentSession?.resources ?? payload.resources),
975
1079
  );
1080
+ const toolsProvided = hasOwnProperty(rawPayload, "tools");
976
1081
  const requestedTools = validateToolRefs(
977
- hasOwnProperty(rawPayload, "tools") ? payload.tools : (parentSession?.tools ?? payload.tools),
1082
+ toolsProvided ? payload.tools : (parentSession?.tools ?? payload.tools),
978
1083
  runtimeSettings,
979
1084
  );
980
- const defaultedTools =
981
- hasOwnProperty(rawPayload, "tools") || parentSession
982
- ? requestedTools
983
- : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
1085
+ let selectedTools: ToolRef[];
1086
+ let toolPolicy: SessionToolPolicy;
1087
+ if (parentSession) {
1088
+ const parentTracksWorkspaceDefaults = parentSession.toolPolicy?.mode === "workspace_default";
1089
+ const parentEffective = withFirstPartyTools(
1090
+ parentTracksWorkspaceDefaults
1091
+ ? withDefaultEnabledCapabilityMcpTools(
1092
+ availableToolRefs(parentSession.tools, runtimeSettings),
1093
+ settings,
1094
+ runtimeSettings,
1095
+ )
1096
+ : parentSession.tools,
1097
+ runtimeSettings,
1098
+ );
1099
+ if (toolsProvided) {
1100
+ assertToolRefsSubset(
1101
+ requestedTools,
1102
+ parentEffective,
1103
+ "child tools may only narrow the parent session tool policy",
1104
+ );
1105
+ selectedTools = requestedTools;
1106
+ toolPolicy = { mode: "explicit", inheritedFromSessionId: parentSession.id };
1107
+ } else {
1108
+ selectedTools = parentEffective;
1109
+ toolPolicy = {
1110
+ mode: parentTracksWorkspaceDefaults ? "workspace_default" : "inherited",
1111
+ inheritedFromSessionId: parentSession.id,
1112
+ };
1113
+ }
1114
+ } else if (toolsProvided) {
1115
+ selectedTools = requestedTools;
1116
+ toolPolicy = { mode: "explicit", inheritedFromSessionId: null };
1117
+ } else {
1118
+ selectedTools = withDefaultEnabledCapabilityMcpTools(
1119
+ requestedTools,
1120
+ settings,
1121
+ capabilityRuntimeSettings,
1122
+ );
1123
+ toolPolicy = { mode: "workspace_default", inheritedFromSessionId: null };
1124
+ }
984
1125
  // The first-party MCP server is attached to EVERY session. It hosts the
985
1126
  // session's own metadata tool (set_session_title) + goal tools, and — only
986
1127
  // when the grant carries the permission — the orchestration/variableSet/
987
1128
  // github tools. Capability is gated per-tool by permission, never by whether
988
1129
  // the server is attached, so a bare chat still gets titling while the
989
1130
  // dangerous tools stay off by default.
990
- const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
1131
+ const tools = withFirstPartyTools(selectedTools, runtimeSettings);
991
1132
  await validateGitHubRepositorySelection(db, workspaceId, resources);
992
1133
  if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
993
1134
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -1033,19 +1174,23 @@ export async function createSessionForRequest(
1033
1174
  frozenRigVersionId = rig.activeVersion.id;
1034
1175
  }
1035
1176
  }
1036
- assertConfiguredModel(settings, payload.model);
1177
+ const model = canonicalConfiguredModel(settings, payload.model ?? settings.openaiModel);
1178
+ if (model === null || model === undefined) {
1179
+ throw new Error("effective session model unexpectedly resolved to null");
1180
+ }
1037
1181
  // Session creation persists the EFFECTIVE model — an omitted payload.model
1038
1182
  // stamps the deployment default onto the session — so the policy must vet
1039
1183
  // that effective value, not just explicit ones (a restricted workspace's
1040
1184
  // default-model session would otherwise be born blocked).
1041
- await assertWorkspaceModelPolicyAllows(
1042
- db,
1043
- settings,
1044
- workspaceId,
1045
- payload.model ?? settings.openaiModel,
1046
- );
1047
- const model = payload.model ?? settings.openaiModel;
1185
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
1048
1186
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
1187
+ const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1188
+ modelId: model,
1189
+ requestedModelId: payload.model ?? null,
1190
+ modelSource: payload.model === undefined ? "deployment" : "explicit",
1191
+ reasoningEffort,
1192
+ reasoningSource: payload.reasoningEffort === undefined ? "deployment" : "explicit",
1193
+ });
1049
1194
  // Parent linkage was resolved above, before context validation. A child with
1050
1195
  // no explicit permission override inherits the creating session's effective
1051
1196
  // grant instead of silently expanding to standalone worker defaults.
@@ -1056,8 +1201,30 @@ export async function createSessionForRequest(
1056
1201
  // normal worker defaults. A child omission inherits its creator's exact
1057
1202
  // effective grant, preserving a host/operator's narrowed capability boundary
1058
1203
  // through the whole session tree.
1204
+ const parentFirstPartyMcpPermissions = parentSession
1205
+ ? [...(parentSession.firstPartyMcpPermissions ?? DEFAULT_FIRST_PARTY_MCP_PERMISSIONS)]
1206
+ : null;
1207
+ if (
1208
+ parentFirstPartyMcpPermissions &&
1209
+ payload.firstPartyMcpPermissions?.some(
1210
+ (permission) => !hasPermission(parentFirstPartyMcpPermissions, permission),
1211
+ )
1212
+ ) {
1213
+ throw new HTTPException(403, {
1214
+ message: "child first-party MCP permissions may only narrow the parent session grant",
1215
+ });
1216
+ }
1217
+ // A worker-signed creator may itself carry less authority than its parent
1218
+ // session (for example a narrowly delegated spawn token). Inherit the
1219
+ // intersection in the shared canonical default order so null/default parent
1220
+ // policies cannot expand when runtime signing resolves them.
1059
1221
  let firstPartyMcpPermissions =
1060
- payload.firstPartyMcpPermissions ?? (parentSessionId ? [...new Set(grant.permissions)] : null);
1222
+ payload.firstPartyMcpPermissions ??
1223
+ (parentFirstPartyMcpPermissions
1224
+ ? parentFirstPartyMcpPermissions.filter((permission) =>
1225
+ hasPermission(grant.permissions, permission),
1226
+ )
1227
+ : null);
1061
1228
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
1062
1229
  // An empty set would sign an unusable zero-permission token; the default
1063
1230
  // worker set is expressed by omitting the field.
@@ -1322,9 +1489,11 @@ export async function createSessionForRequest(
1322
1489
  turnInstructions: payload.turnInstructions ?? null,
1323
1490
  resources,
1324
1491
  tools,
1492
+ toolPolicy,
1325
1493
  ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1326
1494
  model,
1327
1495
  reasoningEffort,
1496
+ turnExecutionPolicy,
1328
1497
  // A shared spawn inherits the box's backend; a caller-supplied
1329
1498
  // sandboxBackend on a shared spawn is ignored (it is the same box). A
1330
1499
  // machine-targeted top-level create labels the home "selfhosted"
@@ -1355,6 +1524,9 @@ export async function createSessionForRequest(
1355
1524
  sessionMcpServers: sessionMcpServers.metadata,
1356
1525
  parentSessionId,
1357
1526
  createIdempotencyKey: payload.idempotencyKey ?? null,
1527
+ maxNestedAgentDepthOverride: payload.maxNestedAgentDepth ?? null,
1528
+ allowNestedAgentDepthIncrease: hasPermission(grant.permissions, "workspace:admin"),
1529
+ subjectId: grant.subjectId,
1358
1530
  // Create-time machine targeting (A-2a): when a target sandbox is named, the
1359
1531
  // active-sandbox pointer is seeded race-free inside createAndStartSession
1360
1532
  // (after the row exists, before the first turn dispatches). Validation
@@ -1362,6 +1534,13 @@ export async function createSessionForRequest(
1362
1534
  seedTargetSandbox: payload.targetSandboxId
1363
1535
  ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
1364
1536
  : null,
1537
+ consumeNewSessionDraft:
1538
+ payload.expectedNewSessionDraftRevision !== undefined
1539
+ ? {
1540
+ subjectId: grant.subjectId,
1541
+ expectedRevision: payload.expectedNewSessionDraftRevision,
1542
+ }
1543
+ : null,
1365
1544
  });
1366
1545
  } catch (error) {
1367
1546
  if (error instanceof AgentCommandAuthorityError) {
@@ -1396,8 +1575,9 @@ export async function createSessionForRequest(
1396
1575
  * Full accept-user-message flow shared by the `user.message` branch of
1397
1576
  * `POST /sessions/:id/events` and the first-party MCP `session_send_message`
1398
1577
  * tool: resource/tool validation, usage limits, the locked append + turn
1399
- * enqueue, and usage recording. `toolsProvided: false` applies the
1400
- * workspace's default capability MCP tools, matching an absent `tools` key.
1578
+ * enqueue, and usage recording. `toolsProvided: false` durably preserves an
1579
+ * absent `tools` key so execution inherits the session policy; an explicit
1580
+ * empty array is a deliberate per-turn narrowing.
1401
1581
  */
1402
1582
  export async function acceptSessionUserMessage(
1403
1583
  deps: AcceptSessionUserMessageDependencies,
@@ -1420,6 +1600,12 @@ export async function acceptSessionUserMessage(
1420
1600
  expectedDraftRevision?: number | null;
1421
1601
  },
1422
1602
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1603
+ if (input.toolsProvided && !deps.settings.sessionTurnToolReplacementEnabled) {
1604
+ throw new HTTPException(503, {
1605
+ message:
1606
+ "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",
1607
+ });
1608
+ }
1423
1609
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1424
1610
  await requireSessionAuthorization(deps, grant, {
1425
1611
  sessionId,
@@ -1435,21 +1621,55 @@ export async function acceptSessionUserMessage(
1435
1621
  // turn's effective model (a follow-up turn inherits the session's model). A
1436
1622
  // pure read with no side effects.
1437
1623
  const existingSession = await requireSession(db, workspaceId, sessionId);
1624
+ const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
1625
+ const effectiveModel =
1626
+ canonicalConfiguredModel(settings, requestedModel ?? existingSession.model) ?? null;
1627
+ if (effectiveModel === null) {
1628
+ throw new Error("effective follow-up model unexpectedly resolved to null");
1629
+ }
1630
+ const sessionReasoningEffort = reasoningEffortForSession(
1631
+ existingSession.metadata,
1632
+ settings.openaiReasoningEffort,
1633
+ );
1634
+ const effectiveReasoningEffort = input.reasoningEffort ?? sessionReasoningEffort;
1635
+ const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1636
+ modelId: effectiveModel,
1637
+ requestedModelId: input.model ?? null,
1638
+ modelSource: input.model == null ? "session" : "explicit",
1639
+ reasoningEffort: effectiveReasoningEffort,
1640
+ reasoningSource: input.reasoningEffort == null ? "session" : "explicit",
1641
+ });
1438
1642
  const runtimeSettings = settingsWithSessionMcpServerMetadata(
1439
1643
  capabilityRuntimeSettings,
1440
1644
  existingSession.mcpServers,
1441
1645
  );
1442
1646
  const requestedResources = normalizeResources(input.resources ?? []);
1443
- const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
1444
- const requestedTools = input.toolsProvided
1445
- ? validatedTools
1446
- : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, capabilityRuntimeSettings);
1647
+ const tracksWorkspaceDefaults = existingSession.toolPolicy?.mode === "workspace_default";
1648
+ const sessionPolicyTools = withFirstPartyTools(
1649
+ tracksWorkspaceDefaults
1650
+ ? withDefaultEnabledCapabilityMcpTools(
1651
+ availableToolRefs(existingSession.tools, runtimeSettings),
1652
+ settings,
1653
+ capabilityRuntimeSettings,
1654
+ )
1655
+ : existingSession.tools,
1656
+ runtimeSettings,
1657
+ );
1658
+ const validatedTools = input.toolsProvided
1659
+ ? validateToolRefsForSessionPolicy({
1660
+ requested: input.tools ?? [],
1661
+ settings: runtimeSettings,
1662
+ allowedTools: sessionPolicyTools,
1663
+ message: "message tools may only narrow the session tool policy",
1664
+ })
1665
+ : [];
1666
+ const requestedTools = input.toolsProvided ? validatedTools : [];
1447
1667
  await requireLimit(deps, {
1448
1668
  accountId: grant.accountId,
1449
1669
  workspaceId,
1450
1670
  action: "agent_run:create",
1451
1671
  quantity: 1,
1452
- model: input.model ?? existingSession.model,
1672
+ model: effectiveModel,
1453
1673
  });
1454
1674
  if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
1455
1675
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -1478,8 +1698,11 @@ export async function acceptSessionUserMessage(
1478
1698
  turnInstructions: input.turnInstructions ?? null,
1479
1699
  resources: requestedResources,
1480
1700
  tools: requestedTools,
1701
+ toolsProvided: input.toolsProvided,
1481
1702
  model: input.model ?? null,
1482
1703
  reasoningEffort: input.reasoningEffort ?? null,
1704
+ reasoningEffortFallback: sessionReasoningEffort,
1705
+ turnExecutionPolicy,
1483
1706
  mcpCredentialUpdates,
1484
1707
  delivery: input.delivery ?? "send",
1485
1708
  origin: delegatedServiceInitiator ? "operator" : (input.origin ?? "human"),
@@ -1571,6 +1794,68 @@ export async function updateSessionTitle(
1571
1794
  };
1572
1795
  }
1573
1796
 
1797
+ /**
1798
+ * Update one existing session MCP server's approval policy. The database
1799
+ * serializes this write with attempt claim under the session lock: an already
1800
+ * claimed attempt retains its immutable snapshot, while the next claim captures
1801
+ * this value. No attempt is cancelled, restarted, or reinterpreted.
1802
+ */
1803
+ export async function updateSessionMcpApprovalPolicy(
1804
+ deps: {
1805
+ db: Database;
1806
+ bus: EventBus;
1807
+ sessionAuthorization?: SessionAuthorizationPort | null;
1808
+ },
1809
+ grant: AccessGrant,
1810
+ sessionId: string,
1811
+ serverId: string,
1812
+ requireApproval: SessionMcpApprovalPolicy,
1813
+ ): Promise<UpdateSessionMcpApprovalPolicyResponse> {
1814
+ const normalizedPolicy = SessionMcpApprovalPolicy.parse(requireApproval);
1815
+ await requireSessionAuthorization(deps, grant, {
1816
+ sessionId,
1817
+ operation: "session.mcp.approval_policy.write",
1818
+ surface: "core",
1819
+ });
1820
+ requirePermission(grant, "sessions:control");
1821
+
1822
+ const outcome: { server?: SessionMcpServerMetadata } = {};
1823
+ const events = await appendSessionEventsWithLockedSessionUpdate(
1824
+ deps.db,
1825
+ grant.workspaceId,
1826
+ sessionId,
1827
+ async (_session, context) => {
1828
+ const result = await context.updateSessionMcpApprovalPolicy(serverId, normalizedPolicy);
1829
+ if (!result.server) {
1830
+ throw new HTTPException(404, { message: "session MCP server not found" });
1831
+ }
1832
+ outcome.server = result.server;
1833
+ return {
1834
+ events: result.changed
1835
+ ? [
1836
+ {
1837
+ type: "session.mcp.approval_policy.updated" as const,
1838
+ payload: {
1839
+ serverId,
1840
+ effectiveFrom: "next_attempt",
1841
+ },
1842
+ },
1843
+ ]
1844
+ : [],
1845
+ };
1846
+ },
1847
+ );
1848
+ const updatedServer = outcome.server;
1849
+ if (!updatedServer) {
1850
+ throw new Error("session MCP approval policy update returned no server");
1851
+ }
1852
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
1853
+ return {
1854
+ server: updatedServer,
1855
+ effectiveFrom: "next_attempt",
1856
+ };
1857
+ }
1858
+
1574
1859
  export async function readSessionLineage(
1575
1860
  deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1576
1861
  grant: AccessGrant,