@opengeni/db 0.7.3 → 0.9.3
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/dist/{chunk-B22X3IEZ.js → chunk-4LG5NBTC.js} +512 -32
- package/dist/chunk-4LG5NBTC.js.map +1 -0
- package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
- package/dist/chunk-BMFDXFPA.js.map +1 -0
- package/dist/chunk-KW526IJA.js +127 -0
- package/dist/chunk-KW526IJA.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6068 -2090
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +29 -4
- package/dist/migrate.js +3 -1
- package/dist/provision-roles.d.ts +721 -46
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-BN5mB9xZ.d.ts → schema-CdPGTHlD.d.ts} +2118 -119
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +17 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
- package/drizzle/0065_session_attempt_quiescence.sql +26 -0
- package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
- package/drizzle/0067_session_event_payload_bounds.sql +209 -0
- package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
- package/drizzle/0069_session_event_history_backfill.sql +32 -0
- package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
- package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
- package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
- package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
- package/drizzle/0074_session_activity_revisions.sql +72 -0
- package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
- package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
- package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
- package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
- package/drizzle/0095_github_existing_installations.sql +84 -0
- package/drizzle/0096_session_turn_initiators.sql +97 -0
- package/drizzle/0097_host_export_outbox.sql +1220 -0
- package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
- package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
- package/drizzle/0100_session_human_input_requests.sql +89 -0
- package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
- package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
- package/drizzle/0103_host_export_root_session.sql +166 -0
- package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
- package/drizzle/0105_session_turn_instructions.sql +9 -0
- package/package.json +4 -4
- package/src/connection-token-resolver.ts +287 -1
- package/src/event-payload-sanitizer.ts +57 -19
- package/src/index.ts +5377 -1125
- package/src/memory-domain.ts +1 -1
- package/src/migrate.ts +86 -23
- package/src/persistence-errors.ts +252 -0
- package/src/provision-roles.ts +42 -0
- package/src/schema.ts +552 -34
- package/src/session-control.ts +518 -38
- package/src/session-queue-commands.ts +308 -57
- package/src/session-tool-call-settlement.ts +58 -7
- package/src/turn-initiator.ts +155 -0
- package/dist/chunk-7LDU7F5P.js +0 -80
- package/dist/chunk-7LDU7F5P.js.map +0 -1
- package/dist/chunk-B22X3IEZ.js.map +0 -1
- package/dist/chunk-YFQ7SGE4.js.map +0 -1
|
@@ -3,6 +3,14 @@ import {
|
|
|
3
3
|
type McpServerConnectionRef,
|
|
4
4
|
type Settings,
|
|
5
5
|
} from "@opengeni/config";
|
|
6
|
+
import type {
|
|
7
|
+
ConnectionCredentialsPort,
|
|
8
|
+
McpConnectionResourceScope,
|
|
9
|
+
McpCredentialAuthNeededReason,
|
|
10
|
+
McpCredentialsRequest,
|
|
11
|
+
TurnInitiator,
|
|
12
|
+
TurnInitiatorContext,
|
|
13
|
+
} from "@opengeni/contracts";
|
|
6
14
|
import { Buffer } from "node:buffer";
|
|
7
15
|
import { lookup } from "node:dns/promises";
|
|
8
16
|
import { isIP } from "node:net";
|
|
@@ -20,11 +28,13 @@ export type ResolveConnectionCredentialResult =
|
|
|
20
28
|
| { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
|
|
21
29
|
| {
|
|
22
30
|
status: "auth_needed";
|
|
23
|
-
reason:
|
|
31
|
+
reason: McpCredentialAuthNeededReason;
|
|
24
32
|
providerDomain: string;
|
|
33
|
+
provider?: string;
|
|
25
34
|
connectionId?: string;
|
|
26
35
|
scopes?: string[];
|
|
27
36
|
resource?: string;
|
|
37
|
+
selectedResources?: McpConnectionResourceScope[];
|
|
28
38
|
authorizationUrl?: string;
|
|
29
39
|
};
|
|
30
40
|
type AuthNeededReason = Extract<
|
|
@@ -36,11 +46,266 @@ export type ResolveConnectionCredentialInput = {
|
|
|
36
46
|
workspaceId: string;
|
|
37
47
|
subjectId?: string;
|
|
38
48
|
serverId: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
/** @deprecated Use toolName. Retained for the API's pre-existing broker call shape. */
|
|
39
51
|
toolId?: string;
|
|
40
52
|
connectionRef: McpServerConnectionRef;
|
|
41
53
|
forceRefresh?: boolean;
|
|
42
54
|
};
|
|
43
55
|
|
|
56
|
+
export type HostMcpCredentialResolverContext = {
|
|
57
|
+
accountId: string;
|
|
58
|
+
workspaceId: string;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
rootSessionId: string;
|
|
61
|
+
turnId: string;
|
|
62
|
+
attemptId: string | null;
|
|
63
|
+
executionGeneration: number;
|
|
64
|
+
initiator: TurnInitiator;
|
|
65
|
+
initiatorContext: TurnInitiatorContext;
|
|
66
|
+
surface: McpCredentialsRequest["surface"];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export class HostMcpCredentialScopeError extends Error {
|
|
70
|
+
constructor(field: "accountId" | "workspaceId" | "sessionId") {
|
|
71
|
+
super(`host MCP credential ${field} scope mismatch`);
|
|
72
|
+
this.name = "HostMcpCredentialScopeError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class HostMcpCredentialBindingError extends Error {
|
|
77
|
+
constructor(
|
|
78
|
+
field:
|
|
79
|
+
| "provider"
|
|
80
|
+
| "providerDomain"
|
|
81
|
+
| "connectionId"
|
|
82
|
+
| "scopes"
|
|
83
|
+
| "resource"
|
|
84
|
+
| "selectedResources",
|
|
85
|
+
) {
|
|
86
|
+
super(`host MCP credential ${field} binding mismatch`);
|
|
87
|
+
this.name = "HostMcpCredentialBindingError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Adapts the public embedding credential port to the runtime's connection
|
|
93
|
+
* resolver contract. Scope echoes are checked before credential headers can
|
|
94
|
+
* reach a request; the returned object is a fresh copy so a host cannot mutate
|
|
95
|
+
* headers after resolution.
|
|
96
|
+
*/
|
|
97
|
+
export function buildHostConnectionTokenResolver(
|
|
98
|
+
resolve: NonNullable<ConnectionCredentialsPort["mcpCredentials"]>,
|
|
99
|
+
context: HostMcpCredentialResolverContext,
|
|
100
|
+
): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
|
|
101
|
+
return async (input) => {
|
|
102
|
+
if (input.workspaceId !== context.workspaceId) {
|
|
103
|
+
throw new HostMcpCredentialScopeError("workspaceId");
|
|
104
|
+
}
|
|
105
|
+
const toolName = input.toolName ?? input.toolId;
|
|
106
|
+
const request: McpCredentialsRequest = {
|
|
107
|
+
accountId: context.accountId,
|
|
108
|
+
workspaceId: context.workspaceId,
|
|
109
|
+
sessionId: context.sessionId,
|
|
110
|
+
rootSessionId: context.rootSessionId,
|
|
111
|
+
turnId: context.turnId,
|
|
112
|
+
attemptId: context.attemptId,
|
|
113
|
+
executionGeneration: context.executionGeneration,
|
|
114
|
+
initiator: context.initiator,
|
|
115
|
+
initiatorContext: { ...context.initiatorContext },
|
|
116
|
+
surface: context.surface,
|
|
117
|
+
serverId: input.serverId,
|
|
118
|
+
connectionRef: {
|
|
119
|
+
providerDomain: input.connectionRef.providerDomain,
|
|
120
|
+
...(input.connectionRef.provider ? { provider: input.connectionRef.provider } : {}),
|
|
121
|
+
...(input.connectionRef.connectionId
|
|
122
|
+
? { connectionId: input.connectionRef.connectionId }
|
|
123
|
+
: {}),
|
|
124
|
+
...(input.connectionRef.kind ? { kind: input.connectionRef.kind } : {}),
|
|
125
|
+
...(input.connectionRef.scopes ? { scopes: [...input.connectionRef.scopes] } : {}),
|
|
126
|
+
...(input.connectionRef.resource ? { resource: input.connectionRef.resource } : {}),
|
|
127
|
+
...(input.connectionRef.selectedResources
|
|
128
|
+
? { selectedResources: copySelectedResources(input.connectionRef.selectedResources) }
|
|
129
|
+
: {}),
|
|
130
|
+
...(input.connectionRef.subjectScope
|
|
131
|
+
? { subjectScope: input.connectionRef.subjectScope }
|
|
132
|
+
: {}),
|
|
133
|
+
},
|
|
134
|
+
forceRefresh: input.forceRefresh === true,
|
|
135
|
+
...(toolName ? { toolName } : {}),
|
|
136
|
+
...(input.subjectId ? { callerSubjectId: input.subjectId } : {}),
|
|
137
|
+
};
|
|
138
|
+
const result = await resolve(request);
|
|
139
|
+
assertHostMcpCredentialScope(result, context);
|
|
140
|
+
assertHostMcpCredentialBinding(result, input.connectionRef);
|
|
141
|
+
if (result.status === "auth_needed") {
|
|
142
|
+
const authorizationUrl = normalizedAuthorizationUrl(result.authorizationUrl);
|
|
143
|
+
return {
|
|
144
|
+
status: "auth_needed",
|
|
145
|
+
reason: result.reason,
|
|
146
|
+
providerDomain: result.providerDomain,
|
|
147
|
+
...(result.provider ? { provider: result.provider } : {}),
|
|
148
|
+
...(result.connectionId ? { connectionId: result.connectionId } : {}),
|
|
149
|
+
...(result.scopes ? { scopes: [...result.scopes] } : {}),
|
|
150
|
+
...(result.resource ? { resource: result.resource } : {}),
|
|
151
|
+
...(result.selectedResources
|
|
152
|
+
? { selectedResources: copySelectedResources(result.selectedResources) }
|
|
153
|
+
: {}),
|
|
154
|
+
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (result.connectionId.length === 0) {
|
|
158
|
+
throw new Error("host MCP credential returned an empty connectionId");
|
|
159
|
+
}
|
|
160
|
+
const expiresAt = parseHostCredentialExpiry(result.expiresAt);
|
|
161
|
+
return {
|
|
162
|
+
status: "ok",
|
|
163
|
+
headers: normalizedHostCredentialHeaders(result.headers),
|
|
164
|
+
connectionId: result.connectionId,
|
|
165
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertHostMcpCredentialBinding(
|
|
171
|
+
result: Awaited<ReturnType<NonNullable<ConnectionCredentialsPort["mcpCredentials"]>>>,
|
|
172
|
+
requested: McpServerConnectionRef,
|
|
173
|
+
): void {
|
|
174
|
+
if (result.providerDomain !== requested.providerDomain) {
|
|
175
|
+
throw new HostMcpCredentialBindingError("providerDomain");
|
|
176
|
+
}
|
|
177
|
+
if (result.provider !== requested.provider) {
|
|
178
|
+
throw new HostMcpCredentialBindingError("provider");
|
|
179
|
+
}
|
|
180
|
+
if (requested.connectionId && result.connectionId !== requested.connectionId) {
|
|
181
|
+
throw new HostMcpCredentialBindingError("connectionId");
|
|
182
|
+
}
|
|
183
|
+
if (!sameSelectedResources(result.selectedResources, requested.selectedResources)) {
|
|
184
|
+
throw new HostMcpCredentialBindingError("selectedResources");
|
|
185
|
+
}
|
|
186
|
+
if (result.status === "ok") {
|
|
187
|
+
if (!sameStringSet(result.scopes, requested.scopes)) {
|
|
188
|
+
throw new HostMcpCredentialBindingError("scopes");
|
|
189
|
+
}
|
|
190
|
+
if (result.resource !== requested.resource) {
|
|
191
|
+
throw new HostMcpCredentialBindingError("resource");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sameStringSet(left: string[] | undefined, right: string[] | undefined): boolean {
|
|
197
|
+
if (left === undefined || right === undefined) return left === right;
|
|
198
|
+
if (left.length !== right.length) return false;
|
|
199
|
+
const sortedLeft = [...left].sort();
|
|
200
|
+
const sortedRight = [...right].sort();
|
|
201
|
+
return sortedLeft.every((value, index) => value === sortedRight[index]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sameSelectedResources(
|
|
205
|
+
left: McpConnectionResourceScope[] | undefined,
|
|
206
|
+
right: McpConnectionResourceScope[] | undefined,
|
|
207
|
+
): boolean {
|
|
208
|
+
if (left === undefined || right === undefined) return left === right;
|
|
209
|
+
const leftKeys = copySelectedResources(left)
|
|
210
|
+
.map((resource) => `${resource.kind}\0${resource.id}`)
|
|
211
|
+
.sort();
|
|
212
|
+
const rightKeys = copySelectedResources(right)
|
|
213
|
+
.map((resource) => `${resource.kind}\0${resource.id}`)
|
|
214
|
+
.sort();
|
|
215
|
+
return (
|
|
216
|
+
leftKeys.length === rightKeys.length &&
|
|
217
|
+
leftKeys.every((value, index) => value === rightKeys[index])
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function copySelectedResources(
|
|
222
|
+
resources: McpConnectionResourceScope[],
|
|
223
|
+
): McpConnectionResourceScope[] {
|
|
224
|
+
if (resources.length === 0 || resources.length > 256) {
|
|
225
|
+
throw new Error("host MCP credential returned an invalid selected resource count");
|
|
226
|
+
}
|
|
227
|
+
const seen = new Set<string>();
|
|
228
|
+
return resources.map((resource) => {
|
|
229
|
+
if (
|
|
230
|
+
resource.kind !== "repository" ||
|
|
231
|
+
typeof resource.id !== "string" ||
|
|
232
|
+
resource.id.length === 0 ||
|
|
233
|
+
resource.id.length > 512
|
|
234
|
+
) {
|
|
235
|
+
throw new Error("host MCP credential returned an invalid selected resource");
|
|
236
|
+
}
|
|
237
|
+
const key = `${resource.kind}\0${resource.id}`;
|
|
238
|
+
if (seen.has(key)) {
|
|
239
|
+
throw new Error("host MCP credential returned duplicate selected resources");
|
|
240
|
+
}
|
|
241
|
+
seen.add(key);
|
|
242
|
+
return { kind: resource.kind, id: resource.id };
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertHostMcpCredentialScope(
|
|
247
|
+
result: { accountId: string; workspaceId: string; sessionId: string },
|
|
248
|
+
context: HostMcpCredentialResolverContext,
|
|
249
|
+
): void {
|
|
250
|
+
for (const field of ["accountId", "workspaceId", "sessionId"] as const) {
|
|
251
|
+
if (result[field] !== context[field]) {
|
|
252
|
+
throw new HostMcpCredentialScopeError(field);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizedHostCredentialHeaders(headers: Record<string, string>): Record<string, string> {
|
|
258
|
+
const entries = Object.entries(headers);
|
|
259
|
+
if (entries.length === 0 || entries.length > 32) {
|
|
260
|
+
throw new Error("host MCP credential returned an invalid header count");
|
|
261
|
+
}
|
|
262
|
+
const normalized: Record<string, string> = {};
|
|
263
|
+
for (const [name, value] of entries) {
|
|
264
|
+
if (
|
|
265
|
+
name.length > 256 ||
|
|
266
|
+
!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name) ||
|
|
267
|
+
value.length === 0 ||
|
|
268
|
+
value.length > 16_384 ||
|
|
269
|
+
/[\r\n\0]/.test(value)
|
|
270
|
+
) {
|
|
271
|
+
throw new Error("host MCP credential returned an invalid header");
|
|
272
|
+
}
|
|
273
|
+
normalized[name] = value;
|
|
274
|
+
}
|
|
275
|
+
return normalized;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normalizedAuthorizationUrl(value: string | undefined): string | undefined {
|
|
279
|
+
if (!value) return undefined;
|
|
280
|
+
let url: URL;
|
|
281
|
+
try {
|
|
282
|
+
url = new URL(value);
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Error("host MCP credential returned an invalid authorizationUrl");
|
|
285
|
+
}
|
|
286
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
|
|
287
|
+
throw new Error("host MCP credential returned an invalid authorizationUrl");
|
|
288
|
+
}
|
|
289
|
+
return url.toString();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
293
|
+
if (hostname === "localhost" || hostname === "::1") return true;
|
|
294
|
+
if (isIP(hostname) !== 4) return false;
|
|
295
|
+
const [first] = hostname.split(".");
|
|
296
|
+
return first === "127";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function parseHostCredentialExpiry(value: string | null | undefined): Date | null | undefined {
|
|
300
|
+
if (value === undefined) return undefined;
|
|
301
|
+
if (value === null) return null;
|
|
302
|
+
const parsed = new Date(value);
|
|
303
|
+
if (!Number.isFinite(parsed.getTime())) {
|
|
304
|
+
throw new Error("host MCP credential returned an invalid expiresAt");
|
|
305
|
+
}
|
|
306
|
+
return parsed;
|
|
307
|
+
}
|
|
308
|
+
|
|
44
309
|
export type ConnectionBrokerDeps = {
|
|
45
310
|
loadCredential: typeof loadConnectionCredentialForBroker;
|
|
46
311
|
recordRefresh: typeof recordConnectionTokenRefresh;
|
|
@@ -65,6 +330,7 @@ const defaultDeps: ConnectionBrokerDeps = {
|
|
|
65
330
|
|
|
66
331
|
const inflight = new Map<string, Promise<ConnectionCredentialForBroker>>();
|
|
67
332
|
const REFRESH_WINDOW_MS = 60_000;
|
|
333
|
+
const CONNECTION_REFRESH_TIMEOUT_MS = 10_000;
|
|
68
334
|
|
|
69
335
|
export function buildConnectionTokenResolver(
|
|
70
336
|
db: Database,
|
|
@@ -105,9 +371,13 @@ export function buildConnectionTokenResolver(
|
|
|
105
371
|
status: "auth_needed",
|
|
106
372
|
reason: "insufficient_scope",
|
|
107
373
|
providerDomain: ref.providerDomain,
|
|
374
|
+
...(ref.provider ? { provider: ref.provider } : {}),
|
|
108
375
|
connectionId: cred.id,
|
|
109
376
|
scopes: missingScopes,
|
|
110
377
|
...(ref.resource ? { resource: ref.resource } : {}),
|
|
378
|
+
...(ref.selectedResources
|
|
379
|
+
? { selectedResources: copySelectedResources(ref.selectedResources) }
|
|
380
|
+
: {}),
|
|
111
381
|
};
|
|
112
382
|
}
|
|
113
383
|
const headers = headersForCredential(cred);
|
|
@@ -116,9 +386,13 @@ export function buildConnectionTokenResolver(
|
|
|
116
386
|
status: "auth_needed",
|
|
117
387
|
reason: "refresh_failed",
|
|
118
388
|
providerDomain: ref.providerDomain,
|
|
389
|
+
...(ref.provider ? { provider: ref.provider } : {}),
|
|
119
390
|
connectionId: cred.id,
|
|
120
391
|
...(ref.scopes ? { scopes: ref.scopes } : {}),
|
|
121
392
|
...(ref.resource ? { resource: ref.resource } : {}),
|
|
393
|
+
...(ref.selectedResources
|
|
394
|
+
? { selectedResources: copySelectedResources(ref.selectedResources) }
|
|
395
|
+
: {}),
|
|
122
396
|
};
|
|
123
397
|
}
|
|
124
398
|
await deps.recordUsed(db, cred.workspaceId, cred.id);
|
|
@@ -192,6 +466,13 @@ export function buildConnectionTokenResolver(
|
|
|
192
466
|
|
|
193
467
|
return async (input) => {
|
|
194
468
|
const ref = input.connectionRef;
|
|
469
|
+
// Repository-scoped provider bindings require a broker that can prove the
|
|
470
|
+
// selected-resource boundary. The generic standalone credential store has
|
|
471
|
+
// no provider-specific containment adapter, so it must fail closed instead
|
|
472
|
+
// of handing an account-wide token to the configured endpoint.
|
|
473
|
+
if (ref.selectedResources) {
|
|
474
|
+
return authNeeded(ref, "resource_scope_unavailable", ref.connectionId);
|
|
475
|
+
}
|
|
195
476
|
let cred: ConnectionCredentialForBroker | null;
|
|
196
477
|
try {
|
|
197
478
|
cred = await load(input);
|
|
@@ -276,9 +557,13 @@ function authNeeded(
|
|
|
276
557
|
status: "auth_needed",
|
|
277
558
|
reason,
|
|
278
559
|
providerDomain: ref.providerDomain,
|
|
560
|
+
...(ref.provider ? { provider: ref.provider } : {}),
|
|
279
561
|
...(connectionId ? { connectionId } : {}),
|
|
280
562
|
...(ref.scopes ? { scopes: ref.scopes } : {}),
|
|
281
563
|
...(ref.resource ? { resource: ref.resource } : {}),
|
|
564
|
+
...(ref.selectedResources
|
|
565
|
+
? { selectedResources: copySelectedResources(ref.selectedResources) }
|
|
566
|
+
: {}),
|
|
282
567
|
};
|
|
283
568
|
}
|
|
284
569
|
|
|
@@ -394,6 +679,7 @@ export async function refreshOAuthConnectionCredential(
|
|
|
394
679
|
headers,
|
|
395
680
|
body,
|
|
396
681
|
redirect: "manual",
|
|
682
|
+
signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
|
|
397
683
|
});
|
|
398
684
|
if (response.status >= 300 && response.status < 400) {
|
|
399
685
|
throw new ConnectionRefreshHttpError(response.status);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { boundSessionEventPayload } from "@opengeni/contracts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Last line of defense against a session event crashing a whole turn.
|
|
3
5
|
*
|
|
@@ -8,12 +10,10 @@
|
|
|
8
10
|
* such a payload reaches `INSERT INTO session_events`, the driver rejects it
|
|
9
11
|
* ("Failed query: insert into session_events") and the turn dies.
|
|
10
12
|
*
|
|
11
|
-
* `sanitizeEventPayload` deep-walks any payload value (objects, arrays, nested)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the two disallowed classes of code unit -- no meaningful text is lost, no
|
|
16
|
-
* truncation (truncation is handled elsewhere).
|
|
13
|
+
* `sanitizeEventPayload` deep-walks any payload value (objects, arrays, nested),
|
|
14
|
+
* repairs every string, redacts sensitive fields, then applies the canonical
|
|
15
|
+
* byte-bounded human/audit preview. Conversation truth uses
|
|
16
|
+
* `sanitizeModelPayload` below and remains a separate representation.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const REPLACEMENT = "�";
|
|
@@ -89,11 +89,22 @@ export function sanitizeEventString(value: string): string {
|
|
|
89
89
|
* keys are sanitized too -- they are jsonb-constrained the same as values.
|
|
90
90
|
*/
|
|
91
91
|
export function sanitizeEventPayload<T>(payload: T): T {
|
|
92
|
+
// Bound first. The preview walker caps depth/container fan-out and replaces
|
|
93
|
+
// inline media before this sanitizer allocates a deep clone. Reversing this
|
|
94
|
+
// order lets a cyclic, deeply nested, or multi-megabyte tool result exhaust
|
|
95
|
+
// the stack/heap before the durable 64 KiB event boundary can protect it.
|
|
96
|
+
return sanitizeEventPayloadDeep(boundSessionEventPayload(payload));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sanitizeEventPayloadDeep<T>(payload: T): T {
|
|
92
100
|
if (typeof payload === "string") {
|
|
93
101
|
return sanitizeEventString(payload) as unknown as T;
|
|
94
102
|
}
|
|
95
103
|
if (Array.isArray(payload)) {
|
|
96
|
-
return payload.map((item) =>
|
|
104
|
+
return payload.map((item) => sanitizeEventPayloadDeep(item)) as unknown as T;
|
|
105
|
+
}
|
|
106
|
+
if (payload instanceof Date) {
|
|
107
|
+
return safeDateIso(payload) as unknown as T;
|
|
97
108
|
}
|
|
98
109
|
if (payload && typeof payload === "object") {
|
|
99
110
|
const entries = Object.entries(payload as Record<string, unknown>).map(
|
|
@@ -115,21 +126,48 @@ export function sanitizeEventPayload<T>(payload: T): T {
|
|
|
115
126
|
* performs only the database-safety repair (NUL removal and UTF-16 repair).
|
|
116
127
|
*/
|
|
117
128
|
export function sanitizeModelPayload<T>(payload: T): T {
|
|
129
|
+
return sanitizeModelPayloadDeep(payload, new WeakSet<object>(), 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const MODEL_PAYLOAD_SANITIZE_MAX_DEPTH = 64;
|
|
133
|
+
const MODEL_PAYLOAD_CYCLE_MARKER = "[OpenGeni omitted cyclic model payload]";
|
|
134
|
+
const MODEL_PAYLOAD_DEPTH_MARKER = "[OpenGeni omitted model payload beyond database-safety depth]";
|
|
135
|
+
|
|
136
|
+
function sanitizeModelPayloadDeep<T>(payload: T, seen: WeakSet<object>, depth: number): T {
|
|
118
137
|
if (typeof payload === "string") {
|
|
119
138
|
return sanitizeEventString(payload) as unknown as T;
|
|
120
139
|
}
|
|
121
|
-
if (
|
|
122
|
-
|
|
140
|
+
if (!payload || typeof payload !== "object") return payload;
|
|
141
|
+
if (payload instanceof Date) {
|
|
142
|
+
return safeDateIso(payload) as unknown as T;
|
|
123
143
|
}
|
|
124
|
-
if (
|
|
144
|
+
if (depth >= MODEL_PAYLOAD_SANITIZE_MAX_DEPTH) {
|
|
145
|
+
return MODEL_PAYLOAD_DEPTH_MARKER as unknown as T;
|
|
146
|
+
}
|
|
147
|
+
if (seen.has(payload)) return MODEL_PAYLOAD_CYCLE_MARKER as unknown as T;
|
|
148
|
+
seen.add(payload);
|
|
149
|
+
try {
|
|
150
|
+
if (Array.isArray(payload)) {
|
|
151
|
+
return payload.map((item) => sanitizeModelPayloadDeep(item, seen, depth + 1)) as unknown as T;
|
|
152
|
+
}
|
|
125
153
|
return Object.fromEntries(
|
|
126
154
|
Object.entries(payload as Record<string, unknown>).map(([key, value]) => [
|
|
127
155
|
sanitizeEventString(key),
|
|
128
|
-
|
|
156
|
+
sanitizeModelPayloadDeep(value, seen, depth + 1),
|
|
129
157
|
]),
|
|
130
158
|
) as unknown as T;
|
|
159
|
+
} finally {
|
|
160
|
+
seen.delete(payload);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeDateIso(value: Date): string | null {
|
|
165
|
+
try {
|
|
166
|
+
const epoch = Date.prototype.getTime.call(value);
|
|
167
|
+
return Number.isFinite(epoch) ? Date.prototype.toISOString.call(value) : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
131
170
|
}
|
|
132
|
-
return payload;
|
|
133
171
|
}
|
|
134
172
|
|
|
135
173
|
function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
|
|
@@ -142,19 +180,19 @@ function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
|
|
|
142
180
|
if (SENSITIVE_FIELD_NAMES.has(normalizeFieldName(key))) {
|
|
143
181
|
return REDACTED;
|
|
144
182
|
}
|
|
145
|
-
return
|
|
183
|
+
return sanitizeEventPayloadDeep(value);
|
|
146
184
|
}
|
|
147
185
|
|
|
148
186
|
function sanitizeSessionMcpServerList(value: unknown): unknown {
|
|
149
187
|
if (!Array.isArray(value)) {
|
|
150
|
-
return
|
|
188
|
+
return sanitizeEventPayloadDeep(value);
|
|
151
189
|
}
|
|
152
190
|
return value.map((item) => {
|
|
153
191
|
if (!isPlainObject(item)) {
|
|
154
|
-
return
|
|
192
|
+
return sanitizeEventPayloadDeep(item);
|
|
155
193
|
}
|
|
156
194
|
const { headers, headersEncrypted, ...rest } = item;
|
|
157
|
-
const cleaned =
|
|
195
|
+
const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
|
|
158
196
|
const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
|
|
159
197
|
if (headerNames) {
|
|
160
198
|
cleaned.headerNames = headerNames;
|
|
@@ -165,14 +203,14 @@ function sanitizeSessionMcpServerList(value: unknown): unknown {
|
|
|
165
203
|
|
|
166
204
|
function sanitizeMcpCredentialUpdateList(value: unknown): unknown {
|
|
167
205
|
if (!Array.isArray(value)) {
|
|
168
|
-
return
|
|
206
|
+
return sanitizeEventPayloadDeep(value);
|
|
169
207
|
}
|
|
170
208
|
return value.map((item) => {
|
|
171
209
|
if (!isPlainObject(item)) {
|
|
172
|
-
return
|
|
210
|
+
return sanitizeEventPayloadDeep(item);
|
|
173
211
|
}
|
|
174
212
|
const { headers, headersEncrypted, ...rest } = item;
|
|
175
|
-
const cleaned =
|
|
213
|
+
const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
|
|
176
214
|
const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
|
|
177
215
|
if (headerNames) {
|
|
178
216
|
cleaned.headerNames = headerNames;
|