@opengeni/core 0.11.2 → 0.11.8

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.11.8",
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.6",
39
+ "@opengeni/contracts": "^0.19.4",
40
+ "@opengeni/db": "^0.12.6",
41
+ "@opengeni/documents": "^0.2.36",
42
+ "@opengeni/events": "^0.3.27",
43
43
  "@opengeni/observability": "^0.3.0",
44
- "@opengeni/runtime": "^0.13.4",
45
- "@opengeni/storage": "^0.2.25",
44
+ "@opengeni/runtime": "^0.13.10",
45
+ "@opengeni/storage": "^0.2.30",
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,
@@ -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,
@@ -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,
@@ -14,6 +14,7 @@ import {
14
14
  ServiceTurnInitiatorContext,
15
15
  evaluateWorkspaceModelPolicy,
16
16
  reasoningEffortForMetadata,
17
+ stableJson,
17
18
  type AccessGrant,
18
19
  type CreateSessionResponse,
19
20
  type GoalSpec,
@@ -27,6 +28,7 @@ import {
27
28
  type SessionMcpServerInput,
28
29
  type SessionMcpServerMetadata,
29
30
  type UpdateSessionMcpApprovalPolicyResponse,
31
+ type UpdateSessionToolPolicyRequest,
30
32
  type SessionAuthorizationPort,
31
33
  type SessionToolPolicy,
32
34
  type SessionTurn,
@@ -69,6 +71,7 @@ import {
69
71
  AgentCommandAuthorityError,
70
72
  SessionSpawnDeniedDbError,
71
73
  SessionControlConflictError,
74
+ SessionToolPolicyVersionConflictError,
72
75
  type SessionCommandActor,
73
76
  } from "@opengeni/db";
74
77
  import {
@@ -104,6 +107,9 @@ import {
104
107
  const reservedSessionMcpServerIds = new Set(["opengeni", "files", "docs", "codex_apps"]);
105
108
  const maxSessionMcpCredentialHeaders = 16;
106
109
  const maxSessionMcpCredentialHeaderValueLength = 4096;
110
+ // Keep the durable snapshot below the shared event-preview array boundary so
111
+ // the generic lossy projection cannot silently rewrite this audit fact.
112
+ const maxToolPolicyAuditRefs = 40;
107
113
  // RFC 9110 field-name token characters.
108
114
  const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
109
115
 
@@ -1856,6 +1862,200 @@ export async function updateSessionMcpApprovalPolicy(
1856
1862
  };
1857
1863
  }
1858
1864
 
1865
+ function toolPolicyAuditSnapshot(
1866
+ session: Session,
1867
+ tools: ToolRef[],
1868
+ policy = session.toolPolicy ?? { mode: "legacy" as const, inheritedFromSessionId: null },
1869
+ ) {
1870
+ // Tool policy refs contain only public server ids and the optional/strict
1871
+ // execution mode; they never carry URLs, names, headers, credentials,
1872
+ // schemas, or arguments. The request is capped at 64 refs and the mandatory
1873
+ // first-party server can add one more, so the complete snapshot remains a
1874
+ // small bounded payload rather than silently dropping security-relevant
1875
+ // optional/strict changes.
1876
+ const allToolRefs = mergeToolRefs([], tools)
1877
+ .sort((left, right) => {
1878
+ // Keep the mandatory first-party authority visible even when the
1879
+ // bounded audit preview has to omit the middle of a large selection.
1880
+ const leftMandatory = left.kind === "mcp" && left.id === "opengeni";
1881
+ const rightMandatory = right.kind === "mcp" && right.id === "opengeni";
1882
+ if (leftMandatory !== rightMandatory) return leftMandatory ? -1 : 1;
1883
+ return `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`);
1884
+ })
1885
+ .map((tool) => ({
1886
+ kind: tool.kind,
1887
+ id: tool.id,
1888
+ ...(tool.optional === undefined ? {} : { optional: tool.optional }),
1889
+ }));
1890
+ const toolRefs = allToolRefs.slice(0, maxToolPolicyAuditRefs);
1891
+ return {
1892
+ mode: policy.mode,
1893
+ inheritedFromSessionId: policy.inheritedFromSessionId,
1894
+ // IDs only: no MCP URLs, names, headers, credentials, schemas, or args.
1895
+ toolIds: [...toolRefs]
1896
+ .sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`))
1897
+ .map((tool) => tool.id),
1898
+ toolRefs,
1899
+ toolCount: allToolRefs.length,
1900
+ truncated: allToolRefs.length > toolRefs.length,
1901
+ };
1902
+ }
1903
+
1904
+ /**
1905
+ * Replace the durable session tool policy. The target and its parent (when
1906
+ * present) are locked by the DB event-writer helper, and the update/event are
1907
+ * committed under one version-fenced transaction. An already claimed turn
1908
+ * keeps its immutable snapshot; the next attempt observes this policy.
1909
+ */
1910
+ export async function updateSessionToolPolicy(
1911
+ deps: {
1912
+ db: Database;
1913
+ bus: EventBus;
1914
+ settings: Settings;
1915
+ sessionAuthorization?: SessionAuthorizationPort | null;
1916
+ },
1917
+ grant: AccessGrant,
1918
+ sessionId: string,
1919
+ request: UpdateSessionToolPolicyRequest,
1920
+ ): Promise<Session> {
1921
+ await requireSessionAuthorization(deps, grant, {
1922
+ sessionId,
1923
+ operation: "session.tool_policy.write",
1924
+ surface: "core",
1925
+ });
1926
+ requirePermission(grant, "sessions:control");
1927
+
1928
+ const existingSession = await requireSession(deps.db, grant.workspaceId, sessionId);
1929
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1930
+ deps.db,
1931
+ grant.workspaceId,
1932
+ deps.settings,
1933
+ );
1934
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(
1935
+ capabilityRuntimeSettings,
1936
+ existingSession.mcpServers,
1937
+ );
1938
+ const explicitRequest = request.mode === "workspace_default" ? null : request;
1939
+ const requestedMode = explicitRequest ? "explicit" : "workspace_default";
1940
+ const explicitRequestedTools = explicitRequest
1941
+ ? (() => {
1942
+ const validatedTools = validateToolRefs(explicitRequest.tools, runtimeSettings);
1943
+ const validatedIds = new Set(validatedTools.map((tool) => `${tool.kind}:${tool.id}`));
1944
+ const unknown = explicitRequest.tools.find(
1945
+ (tool) => !validatedIds.has(`${tool.kind}:${tool.id}`),
1946
+ );
1947
+ if (unknown) {
1948
+ throw new HTTPException(422, { message: `unknown MCP server id: ${unknown.id}` });
1949
+ }
1950
+ return withFirstPartyTools(validatedTools, runtimeSettings);
1951
+ })()
1952
+ : null;
1953
+ const workspaceDefaultTools = withFirstPartyTools(
1954
+ withDefaultEnabledCapabilityMcpTools([], deps.settings, capabilityRuntimeSettings),
1955
+ runtimeSettings,
1956
+ );
1957
+ const events = await appendSessionEventsWithLockedSessionUpdate(
1958
+ deps.db,
1959
+ grant.workspaceId,
1960
+ sessionId,
1961
+ async (session, context) => {
1962
+ const currentVersion = session.toolPolicyVersion ?? 1;
1963
+ if (request.expectedVersion !== currentVersion) {
1964
+ throw new SessionToolPolicyVersionConflictError(currentVersion);
1965
+ }
1966
+
1967
+ let nextTools: ToolRef[];
1968
+ let nextPolicy: SessionToolPolicy;
1969
+ if (session.parentSessionId) {
1970
+ const parent = await context.getLockedSession(session.parentSessionId);
1971
+ if (!parent) {
1972
+ throw new HTTPException(409, { message: "parent session is no longer available" });
1973
+ }
1974
+ const parentTracksWorkspaceDefaults = parent.toolPolicy?.mode === "workspace_default";
1975
+ const parentEffective = withFirstPartyTools(
1976
+ parentTracksWorkspaceDefaults
1977
+ ? withDefaultEnabledCapabilityMcpTools(
1978
+ availableToolRefs(parent.tools, runtimeSettings),
1979
+ deps.settings,
1980
+ runtimeSettings,
1981
+ )
1982
+ : parent.tools,
1983
+ runtimeSettings,
1984
+ );
1985
+ if (requestedMode === "workspace_default") {
1986
+ if (!parentTracksWorkspaceDefaults) {
1987
+ throw new HTTPException(403, {
1988
+ message:
1989
+ "a child may adopt workspace defaults only while its parent tracks workspace defaults",
1990
+ });
1991
+ }
1992
+ nextTools = parentEffective;
1993
+ nextPolicy = {
1994
+ mode: "workspace_default",
1995
+ inheritedFromSessionId: parent.id,
1996
+ };
1997
+ } else {
1998
+ nextTools = explicitRequestedTools!;
1999
+ assertToolRefsSubset(
2000
+ nextTools,
2001
+ parentEffective,
2002
+ "session tools may only narrow the parent session tool policy",
2003
+ );
2004
+ nextPolicy = {
2005
+ mode: "explicit",
2006
+ inheritedFromSessionId: parent.id,
2007
+ };
2008
+ }
2009
+ } else {
2010
+ nextTools =
2011
+ requestedMode === "workspace_default" ? workspaceDefaultTools : explicitRequestedTools!;
2012
+ nextPolicy = { mode: requestedMode, inheritedFromSessionId: null };
2013
+ }
2014
+
2015
+ const currentPolicy = session.toolPolicy ?? {
2016
+ mode: "legacy" as const,
2017
+ inheritedFromSessionId: null,
2018
+ };
2019
+ // JSONB normalizes object-key order on the round trip, so plain
2020
+ // JSON.stringify would turn an identical retry into a second mutation
2021
+ // (and version bump) merely because the persisted key order differs from
2022
+ // the request object. Compare canonical JSON instead.
2023
+ const unchanged =
2024
+ stableJson({ tools: session.tools, policy: currentPolicy }) ===
2025
+ stableJson({ tools: nextTools, policy: nextPolicy });
2026
+ if (unchanged) {
2027
+ return { events: [] };
2028
+ }
2029
+
2030
+ const nextVersion = currentVersion + 1;
2031
+ return {
2032
+ events: [
2033
+ {
2034
+ type: "session.tool_policy.updated" as const,
2035
+ payload: {
2036
+ before: toolPolicyAuditSnapshot(session, session.tools, currentPolicy),
2037
+ after: toolPolicyAuditSnapshot(session, nextTools, nextPolicy),
2038
+ version: nextVersion,
2039
+ effectiveFrom: "next_attempt",
2040
+ },
2041
+ },
2042
+ ],
2043
+ update: {
2044
+ tools: nextTools,
2045
+ toolPolicy: nextPolicy,
2046
+ toolPolicyVersion: nextVersion,
2047
+ expectedToolPolicyVersion: request.expectedVersion,
2048
+ },
2049
+ };
2050
+ },
2051
+ { lockParentSession: true },
2052
+ );
2053
+ if (events.length > 0) {
2054
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
2055
+ }
2056
+ return await requireSession(deps.db, grant.workspaceId, sessionId);
2057
+ }
2058
+
1859
2059
  export async function readSessionLineage(
1860
2060
  deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
1861
2061
  grant: AccessGrant,
@@ -16,10 +16,12 @@ import type { Settings } from "@opengeni/config";
16
16
  import {
17
17
  advanceWorkspaceGenerationForDirectRequest,
18
18
  advanceWorkspaceGenerationForRetainedProcess,
19
+ getRetainedProcess,
19
20
  getSandbox,
20
21
  markWarmLeaseInstanceLost,
21
22
  readActiveSandbox,
22
23
  retainWorkspaceMutationProcess,
24
+ retainedProcessSettlementIdentity,
23
25
  settleRetainedProcess,
24
26
  verifyDirectWorkspaceMutationSettlement,
25
27
  verifyRetainedProcessMutationSettlement,
@@ -55,7 +57,12 @@ export type ChannelARoutingServices = {
55
57
  export function relayConfigFromSettings(settings: Settings): SelfhostedRelayConfig {
56
58
  const raw = settings.selfhostedRelayUrl?.trim();
57
59
  if (!raw) {
58
- return { host: "relay.opengeni.local", port: 443, tls: true, path: "/stream" };
60
+ return {
61
+ host: "relay.opengeni.local",
62
+ port: 443,
63
+ tls: true,
64
+ path: "/stream",
65
+ };
59
66
  }
60
67
  try {
61
68
  const url = new URL(raw.includes("://") ? raw : `wss://${raw}`);
@@ -290,15 +297,34 @@ export function wrapChannelABoxWithRouting(
290
297
  if (
291
298
  backend.sandboxId !== null ||
292
299
  backend.leaseEpoch === undefined ||
293
- backend.providerInstanceId === undefined
300
+ backend.providerInstanceId === undefined ||
301
+ backend.activeEpoch === undefined
294
302
  ) {
295
303
  return;
296
304
  }
305
+ const durable = await getRetainedProcess(db, {
306
+ workspaceId: ids.workspaceId,
307
+ sessionId: ids.sessionId,
308
+ processId: process.id,
309
+ });
310
+ if (
311
+ !durable ||
312
+ durable.providerSessionId !== process.providerSessionId ||
313
+ durable.providerBackend !== backend.kind ||
314
+ durable.providerInstanceId !== backend.providerInstanceId ||
315
+ durable.leaseEpoch !== backend.leaseEpoch ||
316
+ durable.routeKind !== (backend.sandboxId === null ? "home" : "active") ||
317
+ durable.routeTargetId !== backend.sandboxId ||
318
+ durable.routeEpoch !== backend.activeEpoch
319
+ ) {
320
+ throw new Error("API retained-process settlement lost its exact durable backend identity");
321
+ }
297
322
  await settleRetainedProcess(db, {
298
323
  accountId: ids.accountId,
299
324
  workspaceId: ids.workspaceId,
300
325
  sessionId: ids.sessionId,
301
326
  processId: process.id,
327
+ expected: retainedProcessSettlementIdentity(durable),
302
328
  outcome: proof.outcome,
303
329
  exitCode: proof.exitCode,
304
330
  reason: proof.reason,