@opengeni/core 0.8.0 → 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,7 +1,14 @@
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,
5
12
  ServiceTurnInitiator,
6
13
  ServiceTurnInitiatorContext,
7
14
  evaluateWorkspaceModelPolicy,
@@ -14,14 +21,18 @@ import {
14
21
  type ResourceRef,
15
22
  type Session,
16
23
  type SessionEvent,
24
+ SessionMcpApprovalPolicy,
17
25
  type SessionMcpCredentialUpdateInput,
18
26
  type SessionMcpServerInput,
19
27
  type SessionMcpServerMetadata,
28
+ type UpdateSessionMcpApprovalPolicyResponse,
20
29
  type SessionAuthorizationPort,
30
+ type SessionToolPolicy,
21
31
  type SessionTurn,
22
32
  type ToolRef,
23
33
  type TurnInitiator,
24
34
  type TurnInitiatorContext,
35
+ type TurnExecutionPolicyV1,
25
36
  } from "@opengeni/contracts";
26
37
  import {
27
38
  createSession,
@@ -47,6 +58,7 @@ import {
47
58
  listSessionMcpServersForChildInheritance,
48
59
  requireSession,
49
60
  submitHumanPromptInTransaction,
61
+ appendSessionEventsWithLockedSessionUpdate,
50
62
  updateSessionTitle as updateSessionTitleRow,
51
63
  withWorkspaceSubjectRls,
52
64
  type CreateSessionMcpServerInput,
@@ -76,11 +88,14 @@ import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
76
88
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
77
89
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
78
90
  import {
91
+ assertToolRefsSubset,
92
+ availableToolRefs,
79
93
  mergeToolRefs,
80
94
  normalizeResources,
81
95
  validateFileResources,
82
96
  validateGitHubRepositorySelection,
83
97
  validateToolRefs,
98
+ validateToolRefsForSessionPolicy,
84
99
  withDefaultEnabledCapabilityMcpTools,
85
100
  } from "./resources";
86
101
 
@@ -265,6 +280,7 @@ function mcpServerConfigFromMetadata(
265
280
  ...(server.name ? { name: server.name } : {}),
266
281
  url: server.url,
267
282
  cacheToolsList: false,
283
+ requireApproval: server.requireApproval,
268
284
  ...(server.connectionRef ? { connectionRef: server.connectionRef } : {}),
269
285
  };
270
286
  }
@@ -340,6 +356,7 @@ function validateSessionMcpServersForCreate(
340
356
  url: server.url,
341
357
  headerNames: Object.keys(headersEncrypted).sort(),
342
358
  credentialVersion: 1,
359
+ requireApproval: server.requireApproval ?? false,
343
360
  connectionRef: server.connectionRef ?? null,
344
361
  });
345
362
  }
@@ -383,6 +400,7 @@ function validateInheritedSessionMcpServersForCreate(
383
400
  url: server.url,
384
401
  headerNames: Object.keys(server.headersEncrypted ?? {}).sort(),
385
402
  credentialVersion: 1,
403
+ requireApproval: server.requireApproval ?? false,
386
404
  connectionRef: server.connectionRef ?? null,
387
405
  })),
388
406
  };
@@ -436,9 +454,14 @@ export async function createAndStartSession(input: {
436
454
  turnInstructions?: string | null;
437
455
  resources: ResourceRef[];
438
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;
439
461
  clientEventId?: string;
440
462
  model: string;
441
463
  reasoningEffort: Settings["openaiReasoningEffort"];
464
+ turnExecutionPolicy: TurnExecutionPolicyV1;
442
465
  sandboxBackend: Settings["sandboxBackend"];
443
466
  metadata: Record<string, unknown>;
444
467
  createdBy?: TurnInitiator;
@@ -527,6 +550,7 @@ export async function createAndStartSession(input: {
527
550
  initialTurnInstructions: input.turnInstructions ?? null,
528
551
  resources: input.resources,
529
552
  tools: input.tools,
553
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
530
554
  metadata: sessionMetadata,
531
555
  ...(input.createdBy ? { createdBy: input.createdBy } : {}),
532
556
  ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
@@ -560,6 +584,7 @@ export async function createAndStartSession(input: {
560
584
  initialTurnInstructions: input.turnInstructions ?? null,
561
585
  resources: input.resources,
562
586
  tools: input.tools,
587
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
563
588
  metadata: sessionMetadata,
564
589
  ...(input.createdBy ? { createdBy: input.createdBy } : {}),
565
590
  ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
@@ -594,9 +619,11 @@ async function finishStartSession(
594
619
  turnInstructions?: string | null;
595
620
  resources: ResourceRef[];
596
621
  tools: ToolRef[];
622
+ toolPolicy?: SessionToolPolicy;
597
623
  clientEventId?: string;
598
624
  model: string;
599
625
  reasoningEffort: Settings["openaiReasoningEffort"];
626
+ turnExecutionPolicy: TurnExecutionPolicyV1;
600
627
  sandboxBackend: Settings["sandboxBackend"];
601
628
  variableSet?: { id: string; name: string } | null;
602
629
  goal?: GoalSpec | null;
@@ -647,7 +674,9 @@ async function finishStartSession(
647
674
  sessionId: session.id,
648
675
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
649
676
  reasoningEffortFallback: input.reasoningEffort,
677
+ turnExecutionPolicy: input.turnExecutionPolicy,
650
678
  createdEventPayload: {
679
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
651
680
  ...(input.variableSet
652
681
  ? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
653
682
  : {}),
@@ -703,12 +732,16 @@ export function workflowIdForSession(sessionId: string): string {
703
732
  * later) and the MCP surfaces that share them validate identically and cannot
704
733
  * drift.
705
734
  */
706
- 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 {
707
739
  if (model === null || model === undefined) {
708
- return;
740
+ return model;
709
741
  }
710
- if (configuredAllowedModels(settings).includes(model)) {
711
- return;
742
+ const canonicalModel = canonicalizeConfiguredModelId(settings, model);
743
+ if (configuredAllowedModels(settings).includes(canonicalModel)) {
744
+ return canonicalModel;
712
745
  }
713
746
  // Codex subscription models (codex/<slug>) are injected per-workspace by the
714
747
  // worker overlay at turn time, so they are never in the deployment-global
@@ -716,12 +749,16 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
716
749
  // only surfaces them for a connected workspace, and the worker enforces the
717
750
  // actual connection (an unconnected workspace fails the turn with a clear
718
751
  // "no Codex subscription connected" error rather than a misleading 422 here).
719
- if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
720
- return;
752
+ if (settings.codexSubscriptionEnabled && canonicalModel.startsWith(CODEX_MODEL_ID_PREFIX)) {
753
+ return canonicalModel;
721
754
  }
722
755
  throw new HTTPException(422, { message: `model is not available: ${model}` });
723
756
  }
724
757
 
758
+ export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
759
+ canonicalConfiguredModel(settings, model);
760
+ }
761
+
725
762
  /**
726
763
  * Reject a model the WORKSPACE's model policy blocks, at the same choke points
727
764
  * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
@@ -742,18 +779,25 @@ export async function assertWorkspaceModelPolicyAllows(
742
779
  if (model === null || model === undefined) {
743
780
  return;
744
781
  }
782
+ const canonicalModel = canonicalConfiguredModel(settings, model);
783
+ if (canonicalModel === null || canonicalModel === undefined) {
784
+ return;
785
+ }
745
786
  const policy = await getWorkspaceModelPolicy(db, workspaceId);
746
787
  if (!policy) {
747
788
  return;
748
789
  }
749
- const providerId = policyProviderIdForModel(settings, model);
750
- const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
790
+ const providerId = policyProviderIdForModel(settings, canonicalModel);
791
+ const verdict = evaluateWorkspaceModelPolicy(policy, {
792
+ providerId,
793
+ modelId: canonicalModel,
794
+ });
751
795
  if (!verdict.allowed) {
752
796
  throw new HTTPException(422, {
753
797
  message:
754
798
  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`,
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`,
757
801
  });
758
802
  }
759
803
  }
@@ -802,6 +846,7 @@ export async function postUserMessageTurn(input: {
802
846
  turnInstructions?: string | null;
803
847
  resources: ResourceRef[];
804
848
  tools: ToolRef[];
849
+ toolsProvided: boolean;
805
850
  model?: string | null;
806
851
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
807
852
  clientEventId?: string;
@@ -813,9 +858,11 @@ export async function postUserMessageTurn(input: {
813
858
  commandActor?: SessionCommandActor;
814
859
  controlEtag?: string | null;
815
860
  expectedDraftRevision?: number | null;
861
+ reasoningEffortFallback?: Settings["openaiReasoningEffort"];
862
+ turnExecutionPolicy: TurnExecutionPolicyV1;
816
863
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
817
864
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
818
- const requestedModel = input.model ?? null;
865
+ const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
819
866
  const requestedReasoningEffort = input.reasoningEffort ?? null;
820
867
  // Reject an explicit per-message model the host does not expose; an omitted
821
868
  // model inherits the session's model downstream (always a configured id).
@@ -844,9 +891,11 @@ export async function postUserMessageTurn(input: {
844
891
  turnInstructions: input.turnInstructions ?? null,
845
892
  resources: input.resources,
846
893
  tools: input.tools,
894
+ toolsProvided: input.toolsProvided,
847
895
  model: requestedModel,
848
896
  reasoningEffort: requestedReasoningEffort,
849
- reasoningEffortFallback: settings.openaiReasoningEffort,
897
+ reasoningEffortFallback: input.reasoningEffortFallback ?? settings.openaiReasoningEffort,
898
+ turnExecutionPolicy: input.turnExecutionPolicy,
850
899
  source: input.origin === "operator" ? "api" : "user",
851
900
  mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
852
901
  }),
@@ -973,21 +1022,58 @@ export async function createSessionForRequest(
973
1022
  ? payload.resources
974
1023
  : (parentSession?.resources ?? payload.resources),
975
1024
  );
1025
+ const toolsProvided = hasOwnProperty(rawPayload, "tools");
976
1026
  const requestedTools = validateToolRefs(
977
- hasOwnProperty(rawPayload, "tools") ? payload.tools : (parentSession?.tools ?? payload.tools),
1027
+ toolsProvided ? payload.tools : (parentSession?.tools ?? payload.tools),
978
1028
  runtimeSettings,
979
1029
  );
980
- const defaultedTools =
981
- hasOwnProperty(rawPayload, "tools") || parentSession
982
- ? requestedTools
983
- : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
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
+ }
984
1070
  // The first-party MCP server is attached to EVERY session. It hosts the
985
1071
  // session's own metadata tool (set_session_title) + goal tools, and — only
986
1072
  // when the grant carries the permission — the orchestration/variableSet/
987
1073
  // github tools. Capability is gated per-tool by permission, never by whether
988
1074
  // the server is attached, so a bare chat still gets titling while the
989
1075
  // dangerous tools stay off by default.
990
- const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
1076
+ const tools = withFirstPartyTools(selectedTools, runtimeSettings);
991
1077
  await validateGitHubRepositorySelection(db, workspaceId, resources);
992
1078
  if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
993
1079
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -1033,19 +1119,23 @@ export async function createSessionForRequest(
1033
1119
  frozenRigVersionId = rig.activeVersion.id;
1034
1120
  }
1035
1121
  }
1036
- 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
+ }
1037
1126
  // Session creation persists the EFFECTIVE model — an omitted payload.model
1038
1127
  // stamps the deployment default onto the session — so the policy must vet
1039
1128
  // that effective value, not just explicit ones (a restricted workspace's
1040
1129
  // 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;
1130
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
1048
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
+ });
1049
1139
  // Parent linkage was resolved above, before context validation. A child with
1050
1140
  // no explicit permission override inherits the creating session's effective
1051
1141
  // grant instead of silently expanding to standalone worker defaults.
@@ -1056,8 +1146,30 @@ export async function createSessionForRequest(
1056
1146
  // normal worker defaults. A child omission inherits its creator's exact
1057
1147
  // effective grant, preserving a host/operator's narrowed capability boundary
1058
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.
1059
1166
  let firstPartyMcpPermissions =
1060
- payload.firstPartyMcpPermissions ?? (parentSessionId ? [...new Set(grant.permissions)] : null);
1167
+ payload.firstPartyMcpPermissions ??
1168
+ (parentFirstPartyMcpPermissions
1169
+ ? parentFirstPartyMcpPermissions.filter((permission) =>
1170
+ hasPermission(grant.permissions, permission),
1171
+ )
1172
+ : null);
1061
1173
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
1062
1174
  // An empty set would sign an unusable zero-permission token; the default
1063
1175
  // worker set is expressed by omitting the field.
@@ -1322,9 +1434,11 @@ export async function createSessionForRequest(
1322
1434
  turnInstructions: payload.turnInstructions ?? null,
1323
1435
  resources,
1324
1436
  tools,
1437
+ toolPolicy,
1325
1438
  ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1326
1439
  model,
1327
1440
  reasoningEffort,
1441
+ turnExecutionPolicy,
1328
1442
  // A shared spawn inherits the box's backend; a caller-supplied
1329
1443
  // sandboxBackend on a shared spawn is ignored (it is the same box). A
1330
1444
  // machine-targeted top-level create labels the home "selfhosted"
@@ -1396,8 +1510,9 @@ export async function createSessionForRequest(
1396
1510
  * Full accept-user-message flow shared by the `user.message` branch of
1397
1511
  * `POST /sessions/:id/events` and the first-party MCP `session_send_message`
1398
1512
  * 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.
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.
1401
1516
  */
1402
1517
  export async function acceptSessionUserMessage(
1403
1518
  deps: AcceptSessionUserMessageDependencies,
@@ -1420,6 +1535,12 @@ export async function acceptSessionUserMessage(
1420
1535
  expectedDraftRevision?: number | null;
1421
1536
  },
1422
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
+ }
1423
1544
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1424
1545
  await requireSessionAuthorization(deps, grant, {
1425
1546
  sessionId,
@@ -1435,21 +1556,55 @@ export async function acceptSessionUserMessage(
1435
1556
  // turn's effective model (a follow-up turn inherits the session's model). A
1436
1557
  // pure read with no side effects.
1437
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
+ });
1438
1577
  const runtimeSettings = settingsWithSessionMcpServerMetadata(
1439
1578
  capabilityRuntimeSettings,
1440
1579
  existingSession.mcpServers,
1441
1580
  );
1442
1581
  const requestedResources = normalizeResources(input.resources ?? []);
1443
- const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
1444
- const requestedTools = input.toolsProvided
1445
- ? validatedTools
1446
- : 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 : [];
1447
1602
  await requireLimit(deps, {
1448
1603
  accountId: grant.accountId,
1449
1604
  workspaceId,
1450
1605
  action: "agent_run:create",
1451
1606
  quantity: 1,
1452
- model: input.model ?? existingSession.model,
1607
+ model: effectiveModel,
1453
1608
  });
1454
1609
  if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
1455
1610
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -1478,8 +1633,11 @@ export async function acceptSessionUserMessage(
1478
1633
  turnInstructions: input.turnInstructions ?? null,
1479
1634
  resources: requestedResources,
1480
1635
  tools: requestedTools,
1636
+ toolsProvided: input.toolsProvided,
1481
1637
  model: input.model ?? null,
1482
1638
  reasoningEffort: input.reasoningEffort ?? null,
1639
+ reasoningEffortFallback: sessionReasoningEffort,
1640
+ turnExecutionPolicy,
1483
1641
  mcpCredentialUpdates,
1484
1642
  delivery: input.delivery ?? "send",
1485
1643
  origin: delegatedServiceInitiator ? "operator" : (input.origin ?? "human"),
@@ -1571,6 +1729,68 @@ export async function updateSessionTitle(
1571
1729
  };
1572
1730
  }
1573
1731
 
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
+
1574
1794
  export async function readSessionLineage(
1575
1795
  deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1576
1796
  grant: AccessGrant,
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export * from "./domain/environments";
57
57
  export * from "./rigs";
58
58
  export * from "./domain/packs";
59
59
  export * from "./domain/resources";
60
+ export * from "./domain/session-tool-policy";
60
61
  export * from "./domain/scheduled-tasks";
61
62
  export * from "./domain/sessions";
62
63
  export * from "./domain/workspace-members";