@opengeni/core 0.11.2 → 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -35,14 +35,14 @@
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
37
  "@opengeni/codex": "^0.2.7",
38
- "@opengeni/config": "^0.7.1",
39
- "@opengeni/contracts": "^0.19.0",
40
- "@opengeni/db": "^0.12.1",
41
- "@opengeni/documents": "^0.2.31",
42
- "@opengeni/events": "^0.3.22",
38
+ "@opengeni/config": "^0.7.7",
39
+ "@opengeni/contracts": "^0.20.0",
40
+ "@opengeni/db": "^0.13.0",
41
+ "@opengeni/documents": "^0.2.37",
42
+ "@opengeni/events": "^0.3.28",
43
43
  "@opengeni/observability": "^0.3.0",
44
- "@opengeni/runtime": "^0.13.4",
45
- "@opengeni/storage": "^0.2.25",
44
+ "@opengeni/runtime": "^0.13.11",
45
+ "@opengeni/storage": "^0.2.31",
46
46
  "hono": "^4.12.18"
47
47
  },
48
48
  "engines": {
@@ -6,7 +6,14 @@ import {
6
6
  } from "@opengeni/contracts";
7
7
  import {
8
8
  getNewSessionDraftInTransaction,
9
+ getEnrollment,
10
+ getRig,
11
+ getSandbox,
12
+ getVariableSet,
9
13
  NewSessionDraftAccessError,
14
+ newSessionDraftToolsProvided,
15
+ publicNewSessionDraftOptions,
16
+ requireFile,
10
17
  saveNewSessionDraftInTransaction,
11
18
  withWorkspaceSubjectRls,
12
19
  } from "@opengeni/db";
@@ -14,15 +21,21 @@ import { HTTPException } from "hono/http-exception";
14
21
  import type { AppDependencies } from "../dependencies";
15
22
  import { settingsWithEnabledCapabilityMcpServers } from "../domain/capabilities";
16
23
  import {
24
+ isAuthoritativeGitHubRepositorySelectionError,
17
25
  normalizeResources,
18
26
  validateFileResources,
19
27
  validateGitHubRepositorySelection,
20
28
  validateToolRefs,
21
29
  } from "../domain/resources";
30
+ import { hasPermission } from "../access";
22
31
  import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "../domain/sessions";
23
32
 
24
33
  type NewSessionDraftDependencies = Pick<AppDependencies, "settings" | "db" | "objectStorage">;
25
34
 
35
+ function hasOwn(value: unknown, key: string): boolean {
36
+ return typeof value === "object" && value !== null && Object.hasOwn(value, key);
37
+ }
38
+
26
39
  function mapNewSessionDraft(
27
40
  row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
28
41
  ): NewSessionDraftValue | null {
@@ -31,14 +44,110 @@ function mapNewSessionDraft(
31
44
  revision: row.revision,
32
45
  text: row.text,
33
46
  resources: row.resources,
34
- tools: row.tools,
47
+ tools: newSessionDraftToolsProvided(row) ? row.tools : [],
48
+ toolsProvided: newSessionDraftToolsProvided(row),
35
49
  model: row.model,
36
50
  reasoningEffort: row.reasoningEffort,
37
- options: row.sessionOptions,
51
+ options: publicNewSessionDraftOptions(row),
38
52
  updatedAt: row.updatedAt.toISOString(),
39
53
  });
40
54
  }
41
55
 
56
+ async function hydrateNewSessionDraft(
57
+ deps: Pick<NewSessionDraftDependencies, "db" | "settings">,
58
+ grant: AccessGrant,
59
+ workspaceId: string,
60
+ row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
61
+ ): Promise<NewSessionDraftValue | null> {
62
+ if (!row) return null;
63
+ const mapped = mapNewSessionDraft(row);
64
+ if (!mapped) return null;
65
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
66
+ deps.db,
67
+ workspaceId,
68
+ deps.settings,
69
+ );
70
+ const resources = [] as NewSessionDraftValue["resources"];
71
+ for (const resource of mapped.resources) {
72
+ if (resource.kind === "repository") {
73
+ try {
74
+ await validateGitHubRepositorySelection(deps.db, workspaceId, [resource]);
75
+ resources.push(resource);
76
+ } catch (error) {
77
+ if (isAuthoritativeGitHubRepositorySelectionError(error)) {
78
+ // Repository authorization can be revoked after the draft was saved.
79
+ // The next form must not present the stale identity as selectable.
80
+ continue;
81
+ }
82
+ // A catalog/database outage is not proof that a repository was revoked.
83
+ // Preserve the resource so a later retry cannot autosave its deletion.
84
+ resources.push(resource);
85
+ }
86
+ continue;
87
+ }
88
+ try {
89
+ const file = await requireFile(deps.db, workspaceId, resource.fileId);
90
+ if (file.status === "ready") resources.push(resource);
91
+ } catch {
92
+ // Missing, foreign, failed, and pending files are stale draft state.
93
+ }
94
+ }
95
+
96
+ const options = { ...mapped.options };
97
+ if (options.variableSetId) {
98
+ if (
99
+ !hasPermission(grant.permissions, "variable-sets:use") ||
100
+ !(await getVariableSet(deps.db, workspaceId, options.variableSetId))
101
+ ) {
102
+ delete options.variableSetId;
103
+ }
104
+ }
105
+ if (options.rigId) {
106
+ const rig = await getRig(deps.db, workspaceId, options.rigId);
107
+ if (!rig?.activeVersion) delete options.rigId;
108
+ }
109
+ if (options.targetSandboxId) {
110
+ const sandbox = await getSandbox(deps.db, workspaceId, options.targetSandboxId);
111
+ const enrollment = sandbox?.enrollmentId
112
+ ? await getEnrollment(deps.db, workspaceId, sandbox.enrollmentId)
113
+ : null;
114
+ if (
115
+ !sandbox ||
116
+ sandbox.kind !== "selfhosted" ||
117
+ !enrollment ||
118
+ enrollment.status !== "active"
119
+ ) {
120
+ delete options.targetSandboxId;
121
+ delete options.workingDir;
122
+ delete options.sandboxBackend;
123
+ }
124
+ }
125
+
126
+ let tools: NewSessionDraftValue["tools"] = [];
127
+ if (mapped.toolsProvided) {
128
+ try {
129
+ tools = validateToolRefs(mapped.tools, runtimeSettings);
130
+ } catch {
131
+ // A revoked/disabled MCP selection is removed while explicitness remains
132
+ // true, so an explicit empty policy cannot silently widen to defaults.
133
+ tools = mapped.tools.filter((tool) => {
134
+ try {
135
+ validateToolRefs([tool], runtimeSettings);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ });
141
+ }
142
+ }
143
+ return {
144
+ ...mapped,
145
+ resources,
146
+ tools,
147
+ options,
148
+ };
149
+ }
150
+
42
151
  /** Read the authenticated actor's server-authoritative pre-session composer state. */
43
152
  export async function getActorNewSessionDraft(
44
153
  deps: Pick<NewSessionDraftDependencies, "settings" | "db">,
@@ -52,11 +161,12 @@ export async function getActorNewSessionDraft(
52
161
  }),
53
162
  );
54
163
  return (
55
- mapNewSessionDraft(row) ?? {
164
+ (await hydrateNewSessionDraft(deps, grant, workspaceId, row)) ?? {
56
165
  revision: 0,
57
166
  text: "",
58
167
  resources: [],
59
168
  tools: [],
169
+ toolsProvided: false,
60
170
  model: deps.settings.openaiModel,
61
171
  reasoningEffort: deps.settings.openaiReasoningEffort,
62
172
  options: {},
@@ -79,13 +189,18 @@ export async function saveActorNewSessionDraft(
79
189
  rawInput: unknown,
80
190
  ): Promise<NewSessionDraftValue> {
81
191
  const input = SaveNewSessionDraftRequest.parse(rawInput);
192
+ // The pre-marker client contract required `tools` and had no
193
+ // `toolsProvided`. Its array—including []—was the user's complete selection.
194
+ // Do this presence check before Zod's default turns the missing marker into
195
+ // false, preserving old-client → new-server intent safely.
196
+ const toolsProvided = hasOwn(rawInput, "toolsProvided") ? input.toolsProvided : true;
82
197
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
83
198
  deps.db,
84
199
  workspaceId,
85
200
  deps.settings,
86
201
  );
87
202
  const resources = normalizeResources(input.resources);
88
- const tools = validateToolRefs(input.tools, runtimeSettings);
203
+ const tools = toolsProvided ? validateToolRefs(input.tools, runtimeSettings) : [];
89
204
  await validateGitHubRepositorySelection(deps.db, workspaceId, resources);
90
205
  if (resources.some((resource) => resource.kind === "file") && !deps.objectStorage) {
91
206
  throw new HTTPException(503, { message: "object storage is not configured" });
@@ -105,6 +220,7 @@ export async function saveActorNewSessionDraft(
105
220
  text: input.text,
106
221
  resources,
107
222
  tools,
223
+ toolsProvided,
108
224
  model: input.model,
109
225
  reasoningEffort: input.reasoningEffort,
110
226
  options: input.options,
@@ -106,6 +106,8 @@ export type AppDependencies = {
106
106
  managedAuth?: ManagedAuth | null;
107
107
  /** Injectable Codex HTTP transport for deterministic API/provider tests. */
108
108
  codexFetch?: typeof fetch;
109
+ /** Injectable Slack Web API transport for deterministic bot-connection tests. */
110
+ slackFetch?: typeof fetch;
109
111
  // The API process's OWN agent-loop-free sandbox client (constructed from
110
112
  // settings via @opengeni/runtime/sandbox). Undefined when sandboxBackend=none.
111
113
  // This is the foundation of the API-direct control plane: the API resumes
@@ -301,6 +301,15 @@ export async function validateGitHubRepositorySelection(
301
301
  }
302
302
  }
303
303
 
304
+ /**
305
+ * A 422 from repository selection validation is an authoritative stale or
306
+ * revoked identity. Other failures (for example a database/catalog outage)
307
+ * leave the result unknown and must not cause draft hydration to delete it.
308
+ */
309
+ export function isAuthoritativeGitHubRepositorySelectionError(error: unknown): boolean {
310
+ return error instanceof HTTPException && error.status === 422;
311
+ }
312
+
304
313
  export async function validateFileResources(
305
314
  db: Database,
306
315
  workspaceId: string,
@@ -6,6 +6,7 @@ import type {
6
6
  CreateScheduledTaskRequest as CreateScheduledTaskPayload,
7
7
  UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
8
8
  } from "@opengeni/contracts";
9
+ import { OPENGENI_SLACK_BOT_SESSION_METADATA_KEY } from "@opengeni/contracts";
9
10
  import {
10
11
  createScheduledTask,
11
12
  deleteScheduledTask,
@@ -24,6 +25,10 @@ import type { ObjectStorageDependency } from "../dependencies";
24
25
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
25
26
  import { validateVariableSetAttachment } from "./environments";
26
27
  import { assertWorkspaceModelPolicyAllows, canonicalConfiguredModel } from "./sessions";
28
+ import {
29
+ hasReservedOpenGeniSlackBotSessionMetadata,
30
+ validateOpenGeniSlackBotConnectionSelection,
31
+ } from "./slack-bot";
27
32
  import {
28
33
  normalizeResources,
29
34
  validateFileResources,
@@ -197,7 +202,7 @@ export async function validatedScheduledTaskUpdate(input: {
197
202
  if (willHaveVariableSet) {
198
203
  requirePermission(input.grant, "variable-sets:use");
199
204
  }
200
- update.agentConfig = await validateScheduledTaskAgentConfig({
205
+ const nextAgentConfig = await validateScheduledTaskAgentConfig({
201
206
  settings: input.settings,
202
207
  db: input.db,
203
208
  objectStorage: input.objectStorage,
@@ -206,6 +211,18 @@ export async function validatedScheduledTaskUpdate(input: {
206
211
  payload: { agentConfig: input.payload.agentConfig },
207
212
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
208
213
  });
214
+ if (
215
+ input.existing.reusableSessionId &&
216
+ input.existing.runMode === "reusable_session" &&
217
+ (input.existing.agentConfig.slackBotConnectionId ?? null) !==
218
+ (nextAgentConfig.slackBotConnectionId ?? null)
219
+ ) {
220
+ throw new HTTPException(409, {
221
+ message:
222
+ "cannot change the OpenGeni Slack bot connection of a task with a live reusable session; recreate the task",
223
+ });
224
+ }
225
+ update.agentConfig = nextAgentConfig;
209
226
  }
210
227
  return update;
211
228
  }
@@ -355,11 +372,24 @@ async function validateScheduledTaskAgentConfig(input: {
355
372
  if (!prompt) {
356
373
  throw new HTTPException(422, { message: "scheduled task prompt is required" });
357
374
  }
375
+ if (hasReservedOpenGeniSlackBotSessionMetadata(input.payload.agentConfig.metadata)) {
376
+ throw new HTTPException(422, {
377
+ message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
378
+ });
379
+ }
358
380
  await validateGitHubRepositorySelection(input.db, input.workspaceId, resources);
359
381
  if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
360
382
  throw new HTTPException(503, { message: "object storage is not configured" });
361
383
  }
362
384
  await validateFileResources(input.db, input.workspaceId, resources);
385
+ if (input.payload.agentConfig.slackBotConnectionId) {
386
+ await validateOpenGeniSlackBotConnectionSelection(
387
+ input.db,
388
+ input.grant,
389
+ input.workspaceId,
390
+ input.payload.agentConfig.slackBotConnectionId,
391
+ );
392
+ }
363
393
  const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
364
394
  if (requestedMaxDepth !== undefined) {
365
395
  const workspace = await requireWorkspace(input.db, input.workspaceId);
@@ -113,18 +113,17 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
113
113
  const configuredIds = effectiveIds.filter((id) => availableIds.has(id));
114
114
  const configuredIdSet = new Set(configuredIds);
115
115
  const droppedIds = effectiveIds.filter((id) => !configuredIdSet.has(id));
116
- const deferredIds = tracksWorkspaceDefaults
117
- ? sortedIds(
118
- toolRefs
119
- .filter(
120
- (tool) =>
121
- tool.optional === true &&
122
- configuredIdSet.has(tool.id) &&
123
- !mandatoryIdSet.has(tool.id),
124
- )
125
- .map((tool) => tool.id),
126
- )
127
- : [];
116
+ // Lazy discovery is scoped to the effective MCP allow-list, not only to
117
+ // workspace-default capability refs. An explicitly selected connector is
118
+ // directly callable from this same materialized list, so excluding it from
119
+ // the existing router creates a false "no connected sources" result. The
120
+ // mandatory first-party OpenGeni server stays eager; every other configured
121
+ // effective server is eligible for bounded schema disclosure.
122
+ const deferredIds = sortedIds(
123
+ toolRefs
124
+ .filter((tool) => configuredIdSet.has(tool.id) && !mandatoryIdSet.has(tool.id))
125
+ .map((tool) => tool.id),
126
+ );
128
127
  const selectedIds = sortedIds(
129
128
  selectedRefs
130
129
  .filter(
@@ -151,7 +150,7 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
151
150
  effectiveIds: projections.effective.ids,
152
151
  mandatoryIds: projections.mandatory.ids,
153
152
  lazyRouter: {
154
- state: tracksWorkspaceDefaults ? "required" : "disabled",
153
+ state: deferredIds.length > 0 ? "required" : "disabled",
155
154
  deferredIds: projections.deferred.ids,
156
155
  },
157
156
  configuredIds: projections.configured.ids,
@@ -169,6 +168,19 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
169
168
  };
170
169
  }
171
170
 
171
+ /**
172
+ * Native provider tools that belong to the workspace-default capability set
173
+ * follow the same omission/narrowing fence as deferred MCP tools. A durable
174
+ * workspace-default policy receives them; fixed historical policies and an
175
+ * explicit per-turn replacement do not. Provider support remains a separate
176
+ * runtime gate and must also be true before a native tool is attached.
177
+ */
178
+ export function sessionToolPolicyAllowsDefaultNativeTools(
179
+ policy: SessionEffectiveToolPolicy,
180
+ ): boolean {
181
+ return policy.mode === "workspace_default" && policy.lazyRouter.state === "required";
182
+ }
183
+
172
184
  /** Current full runtime registry IDs, including configured static servers. */
173
185
  export async function workspaceSessionToolPolicyServerIds(
174
186
  db: Database,
@@ -9,11 +9,13 @@ import {
9
9
  import {
10
10
  CreateSessionRequest,
11
11
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
12
+ OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
12
13
  SessionSpawnDenial,
13
14
  ServiceTurnInitiator,
14
15
  ServiceTurnInitiatorContext,
15
16
  evaluateWorkspaceModelPolicy,
16
17
  reasoningEffortForMetadata,
18
+ stableJson,
17
19
  type AccessGrant,
18
20
  type CreateSessionResponse,
19
21
  type GoalSpec,
@@ -27,6 +29,7 @@ import {
27
29
  type SessionMcpServerInput,
28
30
  type SessionMcpServerMetadata,
29
31
  type UpdateSessionMcpApprovalPolicyResponse,
32
+ type UpdateSessionToolPolicyRequest,
30
33
  type SessionAuthorizationPort,
31
34
  type SessionToolPolicy,
32
35
  type SessionTurn,
@@ -69,6 +72,7 @@ import {
69
72
  AgentCommandAuthorityError,
70
73
  SessionSpawnDeniedDbError,
71
74
  SessionControlConflictError,
75
+ SessionToolPolicyVersionConflictError,
72
76
  type SessionCommandActor,
73
77
  } from "@opengeni/db";
74
78
  import {
@@ -89,6 +93,7 @@ import { requireSessionAuthorization } from "../session-authorization";
89
93
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
90
94
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
91
95
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
96
+ import { hasReservedOpenGeniSlackBotSessionMetadata } from "./slack-bot";
92
97
  import {
93
98
  assertToolRefsSubset,
94
99
  availableToolRefs,
@@ -104,6 +109,9 @@ import {
104
109
  const reservedSessionMcpServerIds = new Set(["opengeni", "files", "docs", "codex_apps"]);
105
110
  const maxSessionMcpCredentialHeaders = 16;
106
111
  const maxSessionMcpCredentialHeaderValueLength = 4096;
112
+ // Keep the durable snapshot below the shared event-preview array boundary so
113
+ // the generic lossy projection cannot silently rewrite this audit fact.
114
+ const maxToolPolicyAuditRefs = 40;
107
115
  // RFC 9110 field-name token characters.
108
116
  const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
109
117
 
@@ -1021,6 +1029,11 @@ export async function createSessionForRequest(
1021
1029
  ): Promise<Session> {
1022
1030
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1023
1031
  const payload = CreateSessionRequest.parse(rawPayload);
1032
+ if (hasReservedOpenGeniSlackBotSessionMetadata(payload.metadata)) {
1033
+ throw new HTTPException(422, {
1034
+ message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
1035
+ });
1036
+ }
1024
1037
  // A committed keyed denial is the idempotent outcome even if mutable
1025
1038
  // resources, policy, authorization, or budget have changed since the first
1026
1039
  // attempt. Replay it before any of those checks, just as a keyed successful
@@ -1856,6 +1869,200 @@ export async function updateSessionMcpApprovalPolicy(
1856
1869
  };
1857
1870
  }
1858
1871
 
1872
+ function toolPolicyAuditSnapshot(
1873
+ session: Session,
1874
+ tools: ToolRef[],
1875
+ policy = session.toolPolicy ?? { mode: "legacy" as const, inheritedFromSessionId: null },
1876
+ ) {
1877
+ // Tool policy refs contain only public server ids and the optional/strict
1878
+ // execution mode; they never carry URLs, names, headers, credentials,
1879
+ // schemas, or arguments. The request is capped at 64 refs and the mandatory
1880
+ // first-party server can add one more, so the complete snapshot remains a
1881
+ // small bounded payload rather than silently dropping security-relevant
1882
+ // optional/strict changes.
1883
+ const allToolRefs = mergeToolRefs([], tools)
1884
+ .sort((left, right) => {
1885
+ // Keep the mandatory first-party authority visible even when the
1886
+ // bounded audit preview has to omit the middle of a large selection.
1887
+ const leftMandatory = left.kind === "mcp" && left.id === "opengeni";
1888
+ const rightMandatory = right.kind === "mcp" && right.id === "opengeni";
1889
+ if (leftMandatory !== rightMandatory) return leftMandatory ? -1 : 1;
1890
+ return `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`);
1891
+ })
1892
+ .map((tool) => ({
1893
+ kind: tool.kind,
1894
+ id: tool.id,
1895
+ ...(tool.optional === undefined ? {} : { optional: tool.optional }),
1896
+ }));
1897
+ const toolRefs = allToolRefs.slice(0, maxToolPolicyAuditRefs);
1898
+ return {
1899
+ mode: policy.mode,
1900
+ inheritedFromSessionId: policy.inheritedFromSessionId,
1901
+ // IDs only: no MCP URLs, names, headers, credentials, schemas, or args.
1902
+ toolIds: [...toolRefs]
1903
+ .sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`))
1904
+ .map((tool) => tool.id),
1905
+ toolRefs,
1906
+ toolCount: allToolRefs.length,
1907
+ truncated: allToolRefs.length > toolRefs.length,
1908
+ };
1909
+ }
1910
+
1911
+ /**
1912
+ * Replace the durable session tool policy. The target and its parent (when
1913
+ * present) are locked by the DB event-writer helper, and the update/event are
1914
+ * committed under one version-fenced transaction. An already claimed turn
1915
+ * keeps its immutable snapshot; the next attempt observes this policy.
1916
+ */
1917
+ export async function updateSessionToolPolicy(
1918
+ deps: {
1919
+ db: Database;
1920
+ bus: EventBus;
1921
+ settings: Settings;
1922
+ sessionAuthorization?: SessionAuthorizationPort | null;
1923
+ },
1924
+ grant: AccessGrant,
1925
+ sessionId: string,
1926
+ request: UpdateSessionToolPolicyRequest,
1927
+ ): Promise<Session> {
1928
+ await requireSessionAuthorization(deps, grant, {
1929
+ sessionId,
1930
+ operation: "session.tool_policy.write",
1931
+ surface: "core",
1932
+ });
1933
+ requirePermission(grant, "sessions:control");
1934
+
1935
+ const existingSession = await requireSession(deps.db, grant.workspaceId, sessionId);
1936
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1937
+ deps.db,
1938
+ grant.workspaceId,
1939
+ deps.settings,
1940
+ );
1941
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(
1942
+ capabilityRuntimeSettings,
1943
+ existingSession.mcpServers,
1944
+ );
1945
+ const explicitRequest = request.mode === "workspace_default" ? null : request;
1946
+ const requestedMode = explicitRequest ? "explicit" : "workspace_default";
1947
+ const explicitRequestedTools = explicitRequest
1948
+ ? (() => {
1949
+ const validatedTools = validateToolRefs(explicitRequest.tools, runtimeSettings);
1950
+ const validatedIds = new Set(validatedTools.map((tool) => `${tool.kind}:${tool.id}`));
1951
+ const unknown = explicitRequest.tools.find(
1952
+ (tool) => !validatedIds.has(`${tool.kind}:${tool.id}`),
1953
+ );
1954
+ if (unknown) {
1955
+ throw new HTTPException(422, { message: `unknown MCP server id: ${unknown.id}` });
1956
+ }
1957
+ return withFirstPartyTools(validatedTools, runtimeSettings);
1958
+ })()
1959
+ : null;
1960
+ const workspaceDefaultTools = withFirstPartyTools(
1961
+ withDefaultEnabledCapabilityMcpTools([], deps.settings, capabilityRuntimeSettings),
1962
+ runtimeSettings,
1963
+ );
1964
+ const events = await appendSessionEventsWithLockedSessionUpdate(
1965
+ deps.db,
1966
+ grant.workspaceId,
1967
+ sessionId,
1968
+ async (session, context) => {
1969
+ const currentVersion = session.toolPolicyVersion ?? 1;
1970
+ if (request.expectedVersion !== currentVersion) {
1971
+ throw new SessionToolPolicyVersionConflictError(currentVersion);
1972
+ }
1973
+
1974
+ let nextTools: ToolRef[];
1975
+ let nextPolicy: SessionToolPolicy;
1976
+ if (session.parentSessionId) {
1977
+ const parent = await context.getLockedSession(session.parentSessionId);
1978
+ if (!parent) {
1979
+ throw new HTTPException(409, { message: "parent session is no longer available" });
1980
+ }
1981
+ const parentTracksWorkspaceDefaults = parent.toolPolicy?.mode === "workspace_default";
1982
+ const parentEffective = withFirstPartyTools(
1983
+ parentTracksWorkspaceDefaults
1984
+ ? withDefaultEnabledCapabilityMcpTools(
1985
+ availableToolRefs(parent.tools, runtimeSettings),
1986
+ deps.settings,
1987
+ runtimeSettings,
1988
+ )
1989
+ : parent.tools,
1990
+ runtimeSettings,
1991
+ );
1992
+ if (requestedMode === "workspace_default") {
1993
+ if (!parentTracksWorkspaceDefaults) {
1994
+ throw new HTTPException(403, {
1995
+ message:
1996
+ "a child may adopt workspace defaults only while its parent tracks workspace defaults",
1997
+ });
1998
+ }
1999
+ nextTools = parentEffective;
2000
+ nextPolicy = {
2001
+ mode: "workspace_default",
2002
+ inheritedFromSessionId: parent.id,
2003
+ };
2004
+ } else {
2005
+ nextTools = explicitRequestedTools!;
2006
+ assertToolRefsSubset(
2007
+ nextTools,
2008
+ parentEffective,
2009
+ "session tools may only narrow the parent session tool policy",
2010
+ );
2011
+ nextPolicy = {
2012
+ mode: "explicit",
2013
+ inheritedFromSessionId: parent.id,
2014
+ };
2015
+ }
2016
+ } else {
2017
+ nextTools =
2018
+ requestedMode === "workspace_default" ? workspaceDefaultTools : explicitRequestedTools!;
2019
+ nextPolicy = { mode: requestedMode, inheritedFromSessionId: null };
2020
+ }
2021
+
2022
+ const currentPolicy = session.toolPolicy ?? {
2023
+ mode: "legacy" as const,
2024
+ inheritedFromSessionId: null,
2025
+ };
2026
+ // JSONB normalizes object-key order on the round trip, so plain
2027
+ // JSON.stringify would turn an identical retry into a second mutation
2028
+ // (and version bump) merely because the persisted key order differs from
2029
+ // the request object. Compare canonical JSON instead.
2030
+ const unchanged =
2031
+ stableJson({ tools: session.tools, policy: currentPolicy }) ===
2032
+ stableJson({ tools: nextTools, policy: nextPolicy });
2033
+ if (unchanged) {
2034
+ return { events: [] };
2035
+ }
2036
+
2037
+ const nextVersion = currentVersion + 1;
2038
+ return {
2039
+ events: [
2040
+ {
2041
+ type: "session.tool_policy.updated" as const,
2042
+ payload: {
2043
+ before: toolPolicyAuditSnapshot(session, session.tools, currentPolicy),
2044
+ after: toolPolicyAuditSnapshot(session, nextTools, nextPolicy),
2045
+ version: nextVersion,
2046
+ effectiveFrom: "next_attempt",
2047
+ },
2048
+ },
2049
+ ],
2050
+ update: {
2051
+ tools: nextTools,
2052
+ toolPolicy: nextPolicy,
2053
+ toolPolicyVersion: nextVersion,
2054
+ expectedToolPolicyVersion: request.expectedVersion,
2055
+ },
2056
+ };
2057
+ },
2058
+ { lockParentSession: true },
2059
+ );
2060
+ if (events.length > 0) {
2061
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
2062
+ }
2063
+ return await requireSession(deps.db, grant.workspaceId, sessionId);
2064
+ }
2065
+
1859
2066
  export async function readSessionLineage(
1860
2067
  deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1861
2068
  grant: AccessGrant,