@opengeni/db 0.9.3 → 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.
Files changed (54) hide show
  1. package/dist/{chunk-4LG5NBTC.js → chunk-VUKRIBO5.js} +577 -12
  2. package/dist/chunk-VUKRIBO5.js.map +1 -0
  3. package/dist/{chunk-KW526IJA.js → chunk-Y5WZZVQK.js} +80 -4
  4. package/dist/chunk-Y5WZZVQK.js.map +1 -0
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.js +6372 -2189
  7. package/dist/index.js.map +1 -1
  8. package/dist/migrate.d.ts +6 -3
  9. package/dist/migrate.js +1 -1
  10. package/dist/provision-roles.d.ts +1122 -91
  11. package/dist/{schema-CdPGTHlD.d.ts → schema-CnpD6BcX.d.ts} +5908 -3626
  12. package/dist/schema.d.ts +1 -1
  13. package/dist/schema.js +19 -1
  14. package/drizzle/0053_codex_credential_leases.sql +2 -2
  15. package/drizzle/0057_durable_queue_control.sql +1 -1
  16. package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
  17. package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
  18. package/drizzle/0063_session_control_mega_foundation.sql +1 -1
  19. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +1 -1
  20. package/drizzle/0065_codex_subscription_overview.sql +168 -0
  21. package/drizzle/0065_session_tool_policy.sql +38 -0
  22. package/drizzle/0067_session_event_payload_bounds.sql +2 -2
  23. package/drizzle/0068_workspace_control_event_bounds.sql +2 -2
  24. package/drizzle/0069_session_event_history_backfill.sql +2 -2
  25. package/drizzle/0074_session_activity_revisions.sql +2 -2
  26. package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
  27. package/drizzle/0107_host_export_lineage_contract.sql +381 -0
  28. package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
  29. package/drizzle/0109_nested_agent_depth_expand.sql +42 -0
  30. package/drizzle/0110_nested_agent_depth_boundary.sql +480 -0
  31. package/drizzle/0111_nested_agent_depth_backfill.sql +49 -0
  32. package/drizzle/0112_nested_agent_depth_contract.sql +38 -0
  33. package/drizzle/0113_nested_agent_depth_validate.sql +13 -0
  34. package/drizzle/0114_nested_agent_depth_contract.sql +49 -0
  35. package/drizzle/0115_nested_agent_depth_validate.sql +11 -0
  36. package/drizzle/0116_nested_agent_depth_index.sql +4 -0
  37. package/drizzle/0117_sandbox_recovery_generations.sql +699 -0
  38. package/drizzle/0118_new_session_drafts.sql +59 -0
  39. package/drizzle/0119_pending_tool_output_policy.sql +5 -0
  40. package/drizzle/0120_durable_goal_wake.sql +360 -0
  41. package/drizzle/0121_goal_update_idempotency.sql +11 -0
  42. package/package.json +5 -4
  43. package/src/codex-token-resolver.ts +175 -14
  44. package/src/connection-token-resolver.ts +143 -120
  45. package/src/event-payload-sanitizer.ts +32 -2
  46. package/src/index.ts +7734 -1330
  47. package/src/migrate.ts +131 -2
  48. package/src/new-session-drafts.ts +144 -0
  49. package/src/schema.ts +626 -16
  50. package/src/session-control.ts +44 -18
  51. package/src/session-queue-commands.ts +94 -21
  52. package/src/session-tool-call-settlement.ts +6 -1
  53. package/dist/chunk-4LG5NBTC.js.map +0 -1
  54. package/dist/chunk-KW526IJA.js.map +0 -1
@@ -26,6 +26,10 @@ import {
26
26
  CodexReloginRequired,
27
27
  type CodexTokenSnapshot,
28
28
  type CodexUsagePayload,
29
+ type CodexFetch,
30
+ type CodexRateLimitResetCreditsDetails,
31
+ type ResetCreditFetchFailureReason,
32
+ fetchCodexRateLimitResetCredits,
29
33
  fetchCodexUsage,
30
34
  normalizeCodexUsage,
31
35
  refreshCodexToken,
@@ -49,6 +53,92 @@ import {
49
53
  // connected credential. Concurrent calls for the SAME credential still coalesce,
50
54
  // so the one-time refresh token is never double-spent.
51
55
  const inflight = new Map<string, Promise<CodexTokenSnapshot>>();
56
+ const CODEX_TOKEN_REFRESH_TIMEOUT_MS = 6_000;
57
+
58
+ export type CodexTokenDeadlineClock = {
59
+ setTimeout: (callback: () => void, delayMs: number) => ReturnType<typeof globalThis.setTimeout>;
60
+ clearTimeout: (handle: ReturnType<typeof globalThis.setTimeout>) => void;
61
+ };
62
+
63
+ export type CodexTokenDeadlineOptions = {
64
+ timeoutMs?: number | undefined;
65
+ signal?: AbortSignal | undefined;
66
+ clock?: CodexTokenDeadlineClock | undefined;
67
+ };
68
+
69
+ const systemCodexTokenDeadlineClock: CodexTokenDeadlineClock = {
70
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
71
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
72
+ };
73
+
74
+ /**
75
+ * Bound a refresh promise without abandoning its rejection handler when the
76
+ * deadline or cancellation wins. The provider promise is observed exactly
77
+ * once, while the observer itself always fulfills, so a late provider failure
78
+ * cannot become an unhandled rejection or replace the authoritative outcome.
79
+ */
80
+ export async function withCodexTokenDeadline<T>(
81
+ operation: Promise<T>,
82
+ options: CodexTokenDeadlineOptions = {},
83
+ ): Promise<T> {
84
+ const timeoutMs = options.timeoutMs ?? CODEX_TOKEN_REFRESH_TIMEOUT_MS;
85
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
86
+ throw new Error("Codex token refresh timeout must be positive");
87
+ }
88
+ const clock = options.clock ?? systemCodexTokenDeadlineClock;
89
+ const signal = options.signal;
90
+
91
+ return await new Promise<T>((resolve, reject) => {
92
+ let settled = false;
93
+ let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;
94
+
95
+ const cleanup = (): void => {
96
+ if (timeout !== undefined) {
97
+ clock.clearTimeout(timeout);
98
+ timeout = undefined;
99
+ }
100
+ signal?.removeEventListener("abort", onAbort);
101
+ };
102
+
103
+ const settle = (
104
+ outcome: { kind: "resolve"; value: T } | { kind: "reject"; error: unknown },
105
+ ) => {
106
+ if (settled) return;
107
+ settled = true;
108
+ cleanup();
109
+ if (outcome.kind === "resolve") {
110
+ resolve(outcome.value);
111
+ } else {
112
+ reject(outcome.error);
113
+ }
114
+ };
115
+
116
+ const onAbort = (): void => {
117
+ settle({
118
+ kind: "reject",
119
+ error: signal?.reason ?? new Error("Codex token refresh cancelled"),
120
+ });
121
+ };
122
+
123
+ if (signal?.aborted) {
124
+ onAbort();
125
+ } else {
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ timeout = clock.setTimeout(
128
+ () => settle({ kind: "reject", error: new Error("Codex token refresh timed out") }),
129
+ timeoutMs,
130
+ );
131
+ }
132
+
133
+ // Do not use Promise.race here. Its derived promise can obscure which
134
+ // branch owns settlement, while this fulfillment-only observer makes the
135
+ // losing provider branch explicitly consumed after timeout/cancellation.
136
+ void Promise.resolve(operation).then(
137
+ (value) => settle({ kind: "resolve", value }),
138
+ (error) => settle({ kind: "reject", error }),
139
+ );
140
+ });
141
+ }
52
142
 
53
143
  // Dependencies are injectable so the lifecycle logic (single-flight, staleness,
54
144
  // needs_relogin transition) is unit-testable without a database. Production uses
@@ -96,7 +186,10 @@ export function buildCodexTokenResolver(
96
186
  cred: CodexCredentialForRun,
97
187
  ): Promise<CodexTokenSnapshot> => {
98
188
  try {
99
- const next = await deps.refresh(cred.tokens.refreshToken);
189
+ // Bound even injected/custom refresh implementations that ignore abort
190
+ // signals. The provider client has its own AbortController timeout; this
191
+ // outer fence ensures the DB advisory transaction cannot be held forever.
192
+ const next = await withCodexTokenDeadline(deps.refresh(cred.tokens.refreshToken));
100
193
  const tokens = {
101
194
  access_token: next.accessToken ?? cred.tokens.accessToken,
102
195
  refresh_token: next.refreshToken ?? cred.tokens.refreshToken,
@@ -219,6 +312,7 @@ function errorUsagePayload(reason?: "needs_relogin"): CodexUsagePayload {
219
312
  weekly: null,
220
313
  limitReached: false,
221
314
  fetchedAt: new Date().toISOString(),
315
+ rateLimitResetCredits: null,
222
316
  ...(reason ? { reason } : {}),
223
317
  };
224
318
  }
@@ -242,6 +336,7 @@ export async function fetchCodexUsageForAccount(
242
336
  settings: Settings,
243
337
  workspaceId: string,
244
338
  credentialId: string,
339
+ fetchImpl: CodexFetch = fetch,
245
340
  ): Promise<CodexUsagePayload> {
246
341
  const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
247
342
  let token: CodexTokenSnapshot;
@@ -253,12 +348,15 @@ export async function fetchCodexUsageForAccount(
253
348
 
254
349
  let normalized: CodexUsagePayload;
255
350
  try {
256
- const usage = await fetchCodexUsage({
257
- accessToken: token.accessToken,
258
- chatgptAccountId: token.chatgptAccountId,
259
- isFedramp: token.isFedramp,
260
- clientVersion: CODEX_CLIENT_VERSION,
261
- });
351
+ const usage = await fetchCodexUsage(
352
+ {
353
+ accessToken: token.accessToken,
354
+ chatgptAccountId: token.chatgptAccountId,
355
+ isFedramp: token.isFedramp,
356
+ clientVersion: CODEX_CLIENT_VERSION,
357
+ },
358
+ fetchImpl,
359
+ );
262
360
  normalized = normalizeCodexUsage(usage.status, usage.payload);
263
361
  } catch {
264
362
  // A network throw on the /wham/usage read must surface as an error PAYLOAD
@@ -266,17 +364,80 @@ export async function fetchCodexUsageForAccount(
266
364
  return errorUsagePayload();
267
365
  }
268
366
 
269
- if (normalized.fiveHour || normalized.weekly) {
367
+ const parsedQuota =
368
+ normalized.status !== "error" && (normalized.fiveHour != null || normalized.weekly != null);
369
+ if (parsedQuota || normalized.rateLimitResetCredits) {
370
+ const checkedAt = new Date();
371
+ // Quota windows and reset-summary freshness are independent. A malformed
372
+ // usage body can still carry a syntactically valid count; that count may be
373
+ // cached without erasing or falsely refreshing the last valid quota truth.
270
374
  // Cache-write is best-effort: a disconnect under us (false) or a transient
271
- // write error must NOT sink the freshly-read usage we are about to return.
375
+ // write error must NOT sink the freshly-read result we are about to return.
272
376
  await recordCodexAccountUsage(db, workspaceId, credentialId, {
273
- primaryUsedPercent: normalized.fiveHour?.percent ?? null,
274
- primaryResetAt: normalized.fiveHour?.resetAt ? new Date(normalized.fiveHour.resetAt) : null,
275
- secondaryUsedPercent: normalized.weekly?.percent ?? null,
276
- secondaryResetAt: normalized.weekly?.resetAt ? new Date(normalized.weekly.resetAt) : null,
277
- checkedAt: new Date(),
377
+ ...(parsedQuota
378
+ ? {
379
+ primaryUsedPercent: normalized.fiveHour?.percent ?? null,
380
+ primaryResetAt: normalized.fiveHour?.resetAt
381
+ ? new Date(normalized.fiveHour.resetAt)
382
+ : null,
383
+ secondaryUsedPercent: normalized.weekly?.percent ?? null,
384
+ secondaryResetAt: normalized.weekly?.resetAt
385
+ ? new Date(normalized.weekly.resetAt)
386
+ : null,
387
+ checkedAt,
388
+ }
389
+ : {}),
390
+ ...(normalized.rateLimitResetCredits
391
+ ? {
392
+ resetCreditAvailableCount: normalized.rateLimitResetCredits.availableCount,
393
+ resetCreditsCheckedAt: checkedAt,
394
+ }
395
+ : {}),
278
396
  }).catch(() => undefined);
279
397
  }
280
398
 
281
399
  return normalized;
282
400
  }
401
+
402
+ export type CodexRateLimitResetCreditsAccountResult =
403
+ | { ok: true; status: number; details: CodexRateLimitResetCreditsDetails }
404
+ | {
405
+ ok: false;
406
+ status: number;
407
+ reason: ResetCreditFetchFailureReason | "needs_relogin";
408
+ };
409
+
410
+ /**
411
+ * Fresh detailed reset-credit inventory for one exact workspace credential.
412
+ * The token is refreshed through the same resolver as usage and never escapes
413
+ * this server-side function. Detailed rows are returned to the route only and
414
+ * are never persisted as redemption authority.
415
+ */
416
+ export async function fetchCodexRateLimitResetCreditsForAccount(
417
+ db: Database,
418
+ settings: Settings,
419
+ workspaceId: string,
420
+ credentialId: string,
421
+ fetchImpl: CodexFetch = fetch,
422
+ ): Promise<CodexRateLimitResetCreditsAccountResult> {
423
+ const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
424
+ let token: CodexTokenSnapshot;
425
+ try {
426
+ token = await resolver.getToken();
427
+ } catch (error) {
428
+ return {
429
+ ok: false,
430
+ status: 0,
431
+ reason: error instanceof CodexReloginRequired ? "needs_relogin" : "network_error",
432
+ };
433
+ }
434
+ return await fetchCodexRateLimitResetCredits(
435
+ {
436
+ accessToken: token.accessToken,
437
+ chatgptAccountId: token.chatgptAccountId,
438
+ isFedramp: token.isFedramp,
439
+ clientVersion: CODEX_CLIENT_VERSION,
440
+ },
441
+ fetchImpl,
442
+ );
443
+ }
@@ -11,8 +11,17 @@ import type {
11
11
  TurnInitiator,
12
12
  TurnInitiatorContext,
13
13
  } from "@opengeni/contracts";
14
+ import {
15
+ OAUTH_MAX_RESPONSE_BYTES,
16
+ pinnedFetch,
17
+ readResponseJsonBounded,
18
+ undiciFetch,
19
+ validateHttpUrl,
20
+ type DnsLookup,
21
+ type FetchLike,
22
+ } from "@opengeni/network";
23
+ export { isPrivateAddress } from "@opengeni/network";
14
24
  import { Buffer } from "node:buffer";
15
- import { lookup } from "node:dns/promises";
16
25
  import { isIP } from "node:net";
17
26
  import { encryptEnvironmentValue } from "./environment-crypto";
18
27
  import {
@@ -50,6 +59,8 @@ export type ResolveConnectionCredentialInput = {
50
59
  /** @deprecated Use toolName. Retained for the API's pre-existing broker call shape. */
51
60
  toolId?: string;
52
61
  connectionRef: McpServerConnectionRef;
62
+ /** Exact MCP destination whose request would receive the resolved headers. */
63
+ destinationUrl: string;
53
64
  forceRefresh?: boolean;
54
65
  };
55
66
 
@@ -81,7 +92,8 @@ export class HostMcpCredentialBindingError extends Error {
81
92
  | "connectionId"
82
93
  | "scopes"
83
94
  | "resource"
84
- | "selectedResources",
95
+ | "selectedResources"
96
+ | "destinationUrl",
85
97
  ) {
86
98
  super(`host MCP credential ${field} binding mismatch`);
87
99
  this.name = "HostMcpCredentialBindingError";
@@ -102,6 +114,13 @@ export function buildHostConnectionTokenResolver(
102
114
  if (input.workspaceId !== context.workspaceId) {
103
115
  throw new HostMcpCredentialScopeError("workspaceId");
104
116
  }
117
+ const destinationUrl = canonicalHttpUrl(input.destinationUrl);
118
+ if (
119
+ !destinationUrl ||
120
+ !destinationHostMatchesProvider(destinationUrl, input.connectionRef.providerDomain)
121
+ ) {
122
+ throw new HostMcpCredentialBindingError("destinationUrl");
123
+ }
105
124
  const toolName = input.toolName ?? input.toolId;
106
125
  const request: McpCredentialsRequest = {
107
126
  accountId: context.accountId,
@@ -114,6 +133,7 @@ export function buildHostConnectionTokenResolver(
114
133
  initiator: context.initiator,
115
134
  initiatorContext: { ...context.initiatorContext },
116
135
  surface: context.surface,
136
+ destinationUrl,
117
137
  serverId: input.serverId,
118
138
  connectionRef: {
119
139
  providerDomain: input.connectionRef.providerDomain,
@@ -317,6 +337,11 @@ export type ConnectionBrokerDeps = {
317
337
  now: () => Date;
318
338
  };
319
339
 
340
+ export type RefreshTransportOptions = {
341
+ fetchImpl?: FetchLike;
342
+ dnsLookup?: DnsLookup;
343
+ };
344
+
320
345
  const defaultDeps: ConnectionBrokerDeps = {
321
346
  loadCredential: loadConnectionCredentialForBroker,
322
347
  recordRefresh: recordConnectionTokenRefresh,
@@ -337,8 +362,12 @@ export function buildConnectionTokenResolver(
337
362
  settings: Settings,
338
363
  deps: ConnectionBrokerDeps = defaultDeps,
339
364
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
365
+ type CredentialLookupInput = Pick<
366
+ ResolveConnectionCredentialInput,
367
+ "workspaceId" | "connectionRef" | "subjectId"
368
+ >;
340
369
  const load = async (
341
- input: ResolveConnectionCredentialInput,
370
+ input: CredentialLookupInput,
342
371
  ): Promise<ConnectionCredentialForBroker | null> => {
343
372
  const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
344
373
  workspaceId: input.workspaceId,
@@ -361,10 +390,14 @@ export function buildConnectionTokenResolver(
361
390
  const snapshot = async (
362
391
  cred: ConnectionCredentialForBroker,
363
392
  ref: McpServerConnectionRef,
393
+ destinationUrl: string,
364
394
  ): Promise<ResolveConnectionCredentialResult> => {
365
395
  if (cred.status !== "active") {
366
396
  return authNeededForStatus(cred, ref);
367
397
  }
398
+ if (!connectionBindingMatches(cred, ref, destinationUrl)) {
399
+ return authNeeded(ref, "missing_connection", cred.id);
400
+ }
368
401
  const missingScopes = missingRequestedScopes(ref.scopes, cred.grantedScopes);
369
402
  if (missingScopes.length > 0) {
370
403
  return {
@@ -428,7 +461,6 @@ export function buildConnectionTokenResolver(
428
461
  if (persisted) {
429
462
  const current = await load({
430
463
  workspaceId: cred.workspaceId,
431
- serverId: "",
432
464
  connectionRef: { ...ref, connectionId: cred.id },
433
465
  });
434
466
  if (current) {
@@ -437,7 +469,6 @@ export function buildConnectionTokenResolver(
437
469
  }
438
470
  const winner = await load({
439
471
  workspaceId: cred.workspaceId,
440
- serverId: "",
441
472
  connectionRef: { ...ref, connectionId: cred.id },
442
473
  });
443
474
  if (winner?.status === "active") {
@@ -485,6 +516,12 @@ export function buildConnectionTokenResolver(
485
516
  if (cred.status !== "active") {
486
517
  return authNeededForStatus(cred, ref);
487
518
  }
519
+ // Reject an audience/destination mismatch before any provider-side refresh
520
+ // or usage update. Refreshing first would still create an unauthorized
521
+ // external side effect even though the token was never sent to the target.
522
+ if (!connectionBindingMatches(cred, ref, input.destinationUrl)) {
523
+ return authNeeded(ref, "missing_connection", cred.id);
524
+ }
488
525
  if (shouldRefresh(cred, input.forceRefresh === true, deps.now())) {
489
526
  try {
490
527
  cred = await refreshSingleFlight(cred, ref);
@@ -508,10 +545,75 @@ export function buildConnectionTokenResolver(
508
545
  return authNeeded(ref, "refresh_failed", cred.id);
509
546
  }
510
547
  }
511
- return await snapshot(cred, ref);
548
+ return await snapshot(cred, ref, input.destinationUrl);
512
549
  };
513
550
  }
514
551
 
552
+ function connectionBindingMatches(
553
+ cred: ConnectionCredentialForBroker,
554
+ ref: McpServerConnectionRef,
555
+ destinationUrl: string,
556
+ ): boolean {
557
+ if (cred.providerDomain.toLowerCase() !== ref.providerDomain.toLowerCase()) return false;
558
+ if (ref.kind && cred.kind !== ref.kind) return false;
559
+
560
+ const credential = cred.credential as Record<string, unknown>;
561
+ const metadata = cred.metadata as Record<string, unknown>;
562
+ const boundMcpUrl = stringValue(credential.mcp_url) ?? stringValue(metadata.mcpUrl);
563
+ const destination = canonicalHttpUrl(destinationUrl);
564
+ if (!destination) return false;
565
+ if (boundMcpUrl) {
566
+ const binding = canonicalHttpUrl(boundMcpUrl);
567
+ if (!binding || destination !== binding) return false;
568
+ } else if (!destinationHostMatchesProvider(destination, cred.providerDomain)) {
569
+ // Legacy/manual API-key rows may predate mcpUrl metadata. They are still
570
+ // host-bound to their canonical provider domain, never usable as an
571
+ // arbitrary bearer/header source for an unrelated MCP destination.
572
+ return false;
573
+ }
574
+ if (cred.kind !== "oauth2") return true;
575
+ const boundResource = stringValue(credential.resource) ?? stringValue(metadata.resource);
576
+ if (ref.resource) {
577
+ if (!boundResource) return false;
578
+ if (canonicalResource(ref.resource) !== canonicalResource(boundResource)) return false;
579
+ }
580
+ return true;
581
+ }
582
+
583
+ function destinationHostMatchesProvider(destinationUrl: string, providerDomain: string): boolean {
584
+ const destinationHost = new URL(destinationUrl).hostname.toLowerCase();
585
+ const provider = providerDomain
586
+ .trim()
587
+ .toLowerCase()
588
+ .replace(/^\.+|\.+$/g, "");
589
+ return (
590
+ Boolean(provider) && (destinationHost === provider || destinationHost.endsWith(`.${provider}`))
591
+ );
592
+ }
593
+
594
+ function canonicalHttpUrl(value: string): string | null {
595
+ try {
596
+ const url = new URL(value);
597
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
598
+ url.hash = "";
599
+ url.hostname = url.hostname.toLowerCase();
600
+ if (
601
+ (url.protocol === "https:" && url.port === "443") ||
602
+ (url.protocol === "http:" && url.port === "80")
603
+ ) {
604
+ url.port = "";
605
+ }
606
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
607
+ return url.toString();
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ function canonicalResource(value: string): string {
614
+ return canonicalHttpUrl(value) ?? value.trim();
615
+ }
616
+
515
617
  export class ConnectionRefreshHttpError extends Error {
516
618
  readonly httpStatus: number;
517
619
 
@@ -618,7 +720,8 @@ function headersForCredential(cred: ConnectionCredentialForBroker): Record<strin
618
720
  export async function refreshOAuthConnectionCredential(
619
721
  cred: ConnectionCredentialForBroker,
620
722
  ref: McpServerConnectionRef,
621
- settings?: Settings,
723
+ settings: Settings,
724
+ transportOptions: RefreshTransportOptions = {},
622
725
  ): Promise<{
623
726
  credential: Record<string, unknown>;
624
727
  expiresAt: Date | null;
@@ -639,8 +742,14 @@ export async function refreshOAuthConnectionCredential(
639
742
  if (!refreshToken || !tokenEndpoint) {
640
743
  throw new Error("connection has no refresh token endpoint");
641
744
  }
642
- if (settings) {
643
- await assertOAuthEndpointAllowed(tokenEndpoint, settings);
745
+ let validatedTokenEndpoint: string;
746
+ try {
747
+ validatedTokenEndpoint = validateHttpUrl(tokenEndpoint, {
748
+ label: "OAuth refresh token endpoint",
749
+ allowLoopbackHttp: settings.environment === "local" || settings.environment === "test",
750
+ });
751
+ } catch {
752
+ throw new Error("connection has an invalid refresh token endpoint");
644
753
  }
645
754
  const body = new URLSearchParams();
646
755
  body.set("grant_type", "refresh_token");
@@ -674,20 +783,35 @@ export async function refreshOAuthConnectionCredential(
674
783
  if (ref.scopes?.length) {
675
784
  body.set("scope", ref.scopes.join(" "));
676
785
  }
677
- const response = await fetch(tokenEndpoint, {
678
- method: "POST",
679
- headers,
680
- body,
681
- redirect: "manual",
682
- signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
683
- });
786
+ const response = await pinnedFetch(
787
+ validatedTokenEndpoint,
788
+ {
789
+ method: "POST",
790
+ headers,
791
+ body,
792
+ signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
793
+ },
794
+ settings,
795
+ {
796
+ fetchImpl: transportOptions.fetchImpl ?? undiciFetch,
797
+ ...(transportOptions.dnsLookup ? { dnsLookup: transportOptions.dnsLookup } : {}),
798
+ label: "OAuth token endpoint",
799
+ requireHttpsOutsideLocalTest: true,
800
+ },
801
+ );
684
802
  if (response.status >= 300 && response.status < 400) {
803
+ await cancelResponseBody(response);
685
804
  throw new ConnectionRefreshHttpError(response.status);
686
805
  }
687
806
  if (!response.ok) {
807
+ await cancelResponseBody(response);
688
808
  throw new ConnectionRefreshHttpError(response.status);
689
809
  }
690
- const payload = (await response.json()) as Record<string, unknown>;
810
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
811
+ response,
812
+ OAUTH_MAX_RESPONSE_BYTES,
813
+ "OAuth refresh token response",
814
+ );
691
815
  const accessToken = stringValue(payload.access_token);
692
816
  if (!accessToken) {
693
817
  throw new Error("connection refresh response did not include access_token");
@@ -750,107 +874,6 @@ function stringValue(value: unknown): string | undefined {
750
874
  return typeof value === "string" && value.length > 0 ? value : undefined;
751
875
  }
752
876
 
753
- async function assertOAuthEndpointAllowed(rawUrl: string, settings: Settings): Promise<void> {
754
- if (
755
- settings.integrationsAllowPrivateNetworkTargets ||
756
- ["local", "test"].includes(settings.environment)
757
- ) {
758
- return;
759
- }
760
- const url = new URL(rawUrl);
761
- if (url.protocol !== "https:") {
762
- throw new Error("OAuth token endpoint must use https outside local/test");
763
- }
764
- const hostname = url.hostname.toLowerCase();
765
- if (hostname === "localhost" || hostname.endsWith(".localhost")) {
766
- throw new Error("OAuth token endpoint may not target localhost");
767
- }
768
- const literal = isIP(hostname);
769
- const addresses = literal
770
- ? [hostname]
771
- : (await lookup(hostname, { all: true })).map((entry) => entry.address);
772
- if (addresses.some(isPrivateAddress)) {
773
- throw new Error("OAuth token endpoint may not target a private network address");
774
- }
775
- }
776
-
777
- export function isPrivateAddress(address: string): boolean {
778
- const normalized = normalizeAddress(address);
779
- const mapped = ipv4FromMappedIpv6(normalized);
780
- if (mapped) {
781
- return isPrivateIpv4Address(mapped);
782
- }
783
- if (normalized.includes(":")) {
784
- if (isIP(normalized) !== 6) {
785
- return true;
786
- }
787
- return (
788
- normalized === "::1" ||
789
- normalized === "::" ||
790
- normalized.startsWith("fc") ||
791
- normalized.startsWith("fd") ||
792
- normalized.startsWith("fe8") ||
793
- normalized.startsWith("fe9") ||
794
- normalized.startsWith("fea") ||
795
- normalized.startsWith("feb")
796
- );
797
- }
798
- return isPrivateIpv4Address(normalized);
799
- }
800
-
801
- function normalizeAddress(address: string): string {
802
- const trimmed = address.trim().toLowerCase();
803
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
804
- return trimmed.slice(1, -1);
805
- }
806
- return trimmed;
807
- }
808
-
809
- function ipv4FromMappedIpv6(address: string): string | null {
810
- if (!address.startsWith("::ffff:")) {
811
- return null;
812
- }
813
- const embedded = address.slice("::ffff:".length);
814
- if (embedded.includes(".")) {
815
- return embedded;
816
- }
817
- const parts = embedded.split(":");
818
- if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
819
- return null;
820
- }
821
- const high = Number.parseInt(parts[0]!, 16);
822
- const low = Number.parseInt(parts[1]!, 16);
823
- if (
824
- !Number.isInteger(high) ||
825
- !Number.isInteger(low) ||
826
- high < 0 ||
827
- high > 0xffff ||
828
- low < 0 ||
829
- low > 0xffff
830
- ) {
831
- return null;
832
- }
833
- return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
834
- }
835
-
836
- function isPrivateIpv4Address(address: string): boolean {
837
- if (isIP(address) !== 4) {
838
- return true;
839
- }
840
- const parts = address.split(".").map((part) => Number(part));
841
- if (
842
- parts.length !== 4 ||
843
- parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
844
- ) {
845
- return true;
846
- }
847
- const [a, b] = parts as [number, number, number, number];
848
- return (
849
- a === 0 ||
850
- a === 10 ||
851
- a === 127 ||
852
- (a === 169 && b === 254) ||
853
- (a === 172 && b >= 16 && b <= 31) ||
854
- (a === 192 && b === 168)
855
- );
877
+ async function cancelResponseBody(response: Response): Promise<void> {
878
+ await response.body?.cancel().catch(() => undefined);
856
879
  }
@@ -88,12 +88,42 @@ export function sanitizeEventString(value: string): string {
88
88
  * combinations are traversed; non-string leaves pass through untouched. Object
89
89
  * keys are sanitized too -- they are jsonb-constrained the same as values.
90
90
  */
91
- export function sanitizeEventPayload<T>(payload: T): T {
91
+ export type SanitizeEventPayloadOptions = {
92
+ /**
93
+ * Separately trusted, server-created retained-output evidence. Never populate
94
+ * this from a producer-controlled payload field.
95
+ */
96
+ fullEvidence?: unknown;
97
+ };
98
+
99
+ export function sanitizeEventPayload<T>(payload: T, options: SanitizeEventPayloadOptions = {}): T {
92
100
  // Bound first. The preview walker caps depth/container fan-out and replaces
93
101
  // inline media before this sanitizer allocates a deep clone. Reversing this
94
102
  // order lets a cyclic, deeply nested, or multi-megabyte tool result exhaust
95
103
  // the stack/heap before the durable 64 KiB event boundary can protect it.
96
- return sanitizeEventPayloadDeep(boundSessionEventPayload(payload));
104
+ const bounded = boundSessionEventPayload(payload, {
105
+ fullEvidence: options.fullEvidence,
106
+ });
107
+ return sanitizeEventPayloadDeep(
108
+ bounded === payload ? removeProducerTruncationMetadata(bounded) : bounded,
109
+ );
110
+ }
111
+
112
+ /**
113
+ * `truncation` is reserved durable-boundary metadata. An ordinary payload that
114
+ * already fits the envelope otherwise returns by reference, so remove a
115
+ * producer-supplied value before persistence rather than allowing it to forge
116
+ * byte accounting or an available retained-artifact receipt. A payload changed
117
+ * by `boundSessionEventPayload` already carries freshly computed metadata and
118
+ * never reaches this helper.
119
+ */
120
+ function removeProducerTruncationMetadata<T>(payload: T): T {
121
+ if (!isPlainObject(payload)) return payload;
122
+ const descriptor = Object.getOwnPropertyDescriptor(payload, "truncation");
123
+ if (!descriptor?.enumerable) return payload;
124
+ const cleaned = { ...payload };
125
+ delete cleaned.truncation;
126
+ return cleaned as T;
97
127
  }
98
128
 
99
129
  function sanitizeEventPayloadDeep<T>(payload: T): T {