@opengeni/api-router 2.5.0 → 2.6.4

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 (59) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-metrics.d.ts +2 -0
  10. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  11. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  12. package/dist/mcp/remember.d.ts +2 -2
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/session-view.d.ts +1 -0
  15. package/dist/mcp/session-wait.d.ts +19 -0
  16. package/dist/routes/api-keys.d.ts +2 -0
  17. package/dist/routes/browser-sessions.d.ts +1 -0
  18. package/dist/routes/computer-sessions.d.ts +12 -0
  19. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  20. package/dist/routes/workspaces.d.ts +1 -1
  21. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  22. package/dist/workspace-delete-observability.d.ts +10 -0
  23. package/package.json +15 -15
  24. package/src/app.ts +172 -20
  25. package/src/auth/managed-auth-attempt-context.ts +40 -3
  26. package/src/auth/managed-auth-session-adapter.ts +1 -0
  27. package/src/auth/managed-auth.ts +164 -4
  28. package/src/http/sse.ts +279 -45
  29. package/src/integrations/oauth-client.ts +8 -1
  30. package/src/integrations/provider-oauth.ts +12 -2
  31. package/src/interaction-metrics.ts +30 -0
  32. package/src/mcp/company-brain-governed-writes.ts +38 -23
  33. package/src/mcp/company-profile-agent-admin.ts +7 -7
  34. package/src/mcp/remember.ts +19 -8
  35. package/src/mcp/server.ts +318 -26
  36. package/src/mcp/session-wait.ts +56 -8
  37. package/src/routes/api-integrations.ts +2 -2
  38. package/src/routes/api-keys.ts +149 -7
  39. package/src/routes/browser-sessions.ts +10 -2
  40. package/src/routes/capabilities.ts +3 -3
  41. package/src/routes/codex.ts +483 -74
  42. package/src/routes/company-profile.ts +64 -0
  43. package/src/routes/computer-sessions.ts +103 -1
  44. package/src/routes/integration-facets.ts +8 -5
  45. package/src/routes/interaction-resources.ts +7 -1
  46. package/src/routes/managed-auth-session-sets.ts +199 -2
  47. package/src/routes/organization-memberships.ts +28 -8
  48. package/src/routes/packs.ts +5 -5
  49. package/src/routes/plugins.ts +2 -2
  50. package/src/routes/scheduled-tasks.ts +9 -0
  51. package/src/routes/sessions.ts +3 -6
  52. package/src/routes/skills.ts +3 -3
  53. package/src/routes/workspaces.ts +293 -54
  54. package/src/sandbox/channel-a.ts +10 -4
  55. package/src/sandbox/machines.ts +13 -6
  56. package/src/sandbox/metrics-ingestion.ts +157 -3
  57. package/src/sandbox/viewer.ts +20 -1
  58. package/src/workspace-delete-observability.ts +75 -0
  59. package/dist/chunk-QESX7HDK.js.map +0 -1
package/src/http/sse.ts CHANGED
@@ -26,6 +26,8 @@ const WORKSPACE_CONTROL_REPLAY_PAGE_SIZE = 100;
26
26
  export const SSE_QUEUED_FRAME_MAX_COUNT = 1;
27
27
  export const SSE_WRITE_STALL_TIMEOUT_MS = 30_000;
28
28
  export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
29
+ export const HTTP1_BROWSER_SSE_BATCH_MAX_BYTES = 512 * 1024;
30
+ export const HTTP1_BROWSER_SSE_BATCH_CONTENT_TYPE = "application/vnd.opengeni.sse-batch";
29
31
  type SseStreamKind = "session" | "workspace_control" | "workspace_interaction";
30
32
  const activeSseStreams: Record<SseStreamKind, number> = {
31
33
  session: 0,
@@ -41,6 +43,7 @@ export type SseDeliveryBoundObservation = {
41
43
  };
42
44
 
43
45
  export type ByteBoundedSseStreamOptions = {
46
+ connectionLifetimeMs?: number | undefined;
44
47
  maxQueuedBytes?: number;
45
48
  stallTimeoutMs?: number;
46
49
  onStop?: () => void;
@@ -72,16 +75,24 @@ export function createByteBoundedSseStream(
72
75
  ): ByteBoundedSseStream {
73
76
  const maxQueuedBytes = options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES;
74
77
  const stallTimeoutMs = options.stallTimeoutMs ?? SSE_WRITE_STALL_TIMEOUT_MS;
78
+ const connectionLifetimeMs = options.connectionLifetimeMs;
75
79
  if (!Number.isSafeInteger(maxQueuedBytes) || maxQueuedBytes <= 0) {
76
80
  throw new RangeError("SSE byte high-water mark must be a positive safe integer");
77
81
  }
78
82
  if (!Number.isSafeInteger(stallTimeoutMs) || stallTimeoutMs <= 0) {
79
83
  throw new RangeError("SSE write stall timeout must be a positive safe integer");
80
84
  }
85
+ if (
86
+ connectionLifetimeMs !== undefined &&
87
+ (!Number.isSafeInteger(connectionLifetimeMs) || connectionLifetimeMs <= 0)
88
+ ) {
89
+ throw new RangeError("SSE connection lifetime must be a positive safe integer");
90
+ }
81
91
  const encoder = new TextEncoder();
82
92
  let controller!: ReadableStreamDefaultController<Uint8Array>;
83
93
  let stopped = false;
84
94
  let capacityWake: (() => void) | null = null;
95
+ let lifetimeTimer: ReturnType<typeof setTimeout> | null = null;
85
96
  let queuedFrames = 0;
86
97
  let queuedBytes = 0;
87
98
 
@@ -93,6 +104,10 @@ export function createByteBoundedSseStream(
93
104
  const stop = (settle: () => void) => {
94
105
  if (stopped) return;
95
106
  stopped = true;
107
+ if (lifetimeTimer !== null) {
108
+ clearTimeout(lifetimeTimer);
109
+ lifetimeTimer = null;
110
+ }
96
111
  wakeWriter();
97
112
  options.onStop?.();
98
113
  try {
@@ -127,6 +142,9 @@ export function createByteBoundedSseStream(
127
142
  size: () => 1,
128
143
  },
129
144
  );
145
+ if (connectionLifetimeMs !== undefined) {
146
+ lifetimeTimer = setTimeout(() => stop(() => controller.close()), connectionLifetimeMs);
147
+ }
130
148
 
131
149
  return {
132
150
  stream,
@@ -271,6 +289,17 @@ export async function sseSessionStream(
271
289
  signal: AbortSignal,
272
290
  options: SessionSseDeliveryOptions = {},
273
291
  ): Promise<Response> {
292
+ if (isHttp1BrowserBatch(options)) {
293
+ const events = await listSessionEvents(db, workspaceId, sessionId, {
294
+ after,
295
+ limit: SESSION_REPLAY_PAGE_SIZE,
296
+ });
297
+ await options.reauthorize?.();
298
+ return finiteSseBatchResponse(
299
+ coalesceSessionEventDeltas(events).map(formatSessionEventSse),
300
+ options,
301
+ );
302
+ }
274
303
  const durableFanout = requireSessionEventDurableFanoutCapability(bus);
275
304
  const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
276
305
  let lastSent = after;
@@ -299,6 +328,7 @@ export async function sseSessionStream(
299
328
  release?.();
300
329
  };
301
330
  const channel = createByteBoundedSseStream({
331
+ connectionLifetimeMs: options.connectionLifetimeMs,
302
332
  maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
303
333
  ...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
304
334
  onObservation: sseObservationReporter("session", options),
@@ -309,14 +339,14 @@ export async function sseSessionStream(
309
339
  const fail = (error: unknown) => {
310
340
  channel.fail(retryableSseFailure("session event stream delivery failed", error));
311
341
  };
312
- stopReauthorization = startSseReauthorization(options, channel.stopped, fail);
342
+ stopReauthorization = startSseReauthorization(options, channel.stopped, channel.close);
313
343
  let writeTail = Promise.resolve();
314
344
  const writeFrame = (frame: string): Promise<void> => {
315
345
  const write = writeTail.then(async () => {
316
346
  // A periodic check closes an idle stream, while this exact pre-delivery
317
347
  // check prevents a buffered/replayed event from crossing a revocation
318
348
  // boundary merely because its timer has not fired yet.
319
- await options.reauthorize?.();
349
+ await reauthorizeSseOrClose(options, channel);
320
350
  if (!(await channel.write(frame))) throw new SseStreamStoppedError();
321
351
  });
322
352
  writeTail = write.catch(() => {});
@@ -459,14 +489,7 @@ export async function sseSessionStream(
459
489
  detachAbortListener = () => signal.removeEventListener("abort", abort);
460
490
  }
461
491
 
462
- return new Response(channel.stream, {
463
- headers: {
464
- "Content-Type": "text/event-stream; charset=utf-8",
465
- "Cache-Control": "no-cache, no-transform",
466
- Connection: "keep-alive",
467
- ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
468
- },
469
- });
492
+ return await sseHttpResponse(channel.stream, options);
470
493
  }
471
494
 
472
495
  export async function replaySessionEvents(
@@ -506,6 +529,21 @@ export async function sseWorkspaceControlStream(
506
529
  signal: AbortSignal,
507
530
  options: SseDeliveryOptions = {},
508
531
  ): Promise<Response> {
532
+ if (isHttp1BrowserBatch(options)) {
533
+ const events = await listWorkspaceControlEvents(
534
+ db,
535
+ workspaceId,
536
+ after,
537
+ WORKSPACE_CONTROL_REPLAY_PAGE_SIZE,
538
+ );
539
+ await options.reauthorize?.();
540
+ return finiteSseBatchResponse(
541
+ events
542
+ .sort((left, right) => left.sequence - right.sequence)
543
+ .map(formatWorkspaceControlEventSse),
544
+ options,
545
+ );
546
+ }
509
547
  const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
510
548
  let lastSent = after;
511
549
  let bootstrapping = true;
@@ -530,6 +568,7 @@ export async function sseWorkspaceControlStream(
530
568
  release?.();
531
569
  };
532
570
  const channel = createByteBoundedSseStream({
571
+ connectionLifetimeMs: options.connectionLifetimeMs,
533
572
  maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
534
573
  ...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
535
574
  onObservation: sseObservationReporter("workspace_control", options),
@@ -540,11 +579,11 @@ export async function sseWorkspaceControlStream(
540
579
  const fail = (error: unknown) => {
541
580
  channel.fail(retryableSseFailure("workspace control stream delivery failed", error));
542
581
  };
543
- stopReauthorization = startSseReauthorization(options, channel.stopped, fail);
582
+ stopReauthorization = startSseReauthorization(options, channel.stopped, channel.close);
544
583
  let writeTail = Promise.resolve();
545
584
  const writeFrame = (frame: string): Promise<void> => {
546
585
  const write = writeTail.then(async () => {
547
- await options.reauthorize?.();
586
+ await reauthorizeSseOrClose(options, channel);
548
587
  if (!(await channel.write(frame))) throw new SseStreamStoppedError();
549
588
  });
550
589
  writeTail = write.catch(() => {});
@@ -633,14 +672,7 @@ export async function sseWorkspaceControlStream(
633
672
  detachAbortListener = () => signal.removeEventListener("abort", abort);
634
673
  }
635
674
 
636
- return new Response(channel.stream, {
637
- headers: {
638
- "Content-Type": "text/event-stream; charset=utf-8",
639
- "Cache-Control": "no-cache, no-transform",
640
- Connection: "keep-alive",
641
- ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
642
- },
643
- });
675
+ return await sseHttpResponse(channel.stream, options);
644
676
  }
645
677
 
646
678
  export type WorkspaceInteractionSseOptions = SseDeliveryOptions & {
@@ -664,9 +696,37 @@ export async function sseWorkspaceLiveStream(
664
696
  signal: AbortSignal,
665
697
  options: WorkspaceInteractionSseOptions = {},
666
698
  ): Promise<Response> {
699
+ if (isHttp1BrowserBatch(options)) {
700
+ const [controlEvents, interactionState] = await Promise.all([
701
+ listWorkspaceControlEvents(db, workspaceId, controlAfter, WORKSPACE_CONTROL_REPLAY_PAGE_SIZE),
702
+ getWorkspaceInteractionRevisionState(db, { accountId, workspaceId }),
703
+ ]);
704
+ await options.reauthorize?.();
705
+ const frames: string[] = [];
706
+ if (interactionState.revision > interactionAfter) {
707
+ frames.push(
708
+ formatWorkspaceInteractionRevisionSse(
709
+ WorkspaceInteractionRevisionEvent.parse({
710
+ workspaceId,
711
+ sequence: interactionState.revision,
712
+ revision: interactionState.revision,
713
+ type: "workspace.interaction.changed",
714
+ occurredAt: (interactionState.updatedAt ?? new Date()).toISOString(),
715
+ }),
716
+ ),
717
+ );
718
+ }
719
+ frames.push(
720
+ ...controlEvents
721
+ .sort((left, right) => left.sequence - right.sequence)
722
+ .map(formatWorkspaceControlEventSse),
723
+ );
724
+ return finiteSseBatchResponse(frames, options);
725
+ }
667
726
  const upstream = new AbortController();
668
727
  let stopReauthorization = () => {};
669
728
  const channel = createByteBoundedSseStream({
729
+ connectionLifetimeMs: options.connectionLifetimeMs,
670
730
  maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
671
731
  ...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
672
732
  onObservation: sseObservationReporter("workspace_interaction", options),
@@ -682,12 +742,13 @@ export async function sseWorkspaceLiveStream(
682
742
  };
683
743
  if (signal.aborted) abort();
684
744
  else signal.addEventListener("abort", abort, { once: true });
685
- stopReauthorization = startSseReauthorization(options, channel.stopped, (error) => {
686
- channel.fail(retryableSseFailure("workspace live stream authorization failed", error));
687
- });
745
+ stopReauthorization = startSseReauthorization(options, channel.stopped, channel.close);
688
746
 
689
747
  const upstreamOptions: WorkspaceInteractionSseOptions = {
690
748
  ...options,
749
+ connectionLifetimeMs: undefined,
750
+ finiteResponseMaxBytes: undefined,
751
+ finiteResponseMediaType: undefined,
691
752
  reauthorize: undefined,
692
753
  reauthorizeAfterMs: undefined,
693
754
  };
@@ -695,7 +756,7 @@ export async function sseWorkspaceLiveStream(
695
756
  let writeTail = Promise.resolve(true);
696
757
  const write = (frame: string): Promise<boolean> => {
697
758
  const pending = writeTail.then(async () => {
698
- await options.reauthorize?.();
759
+ await reauthorizeSseOrClose(options, channel);
699
760
  return await channel.write(frame);
700
761
  });
701
762
  writeTail = pending.catch(() => false);
@@ -761,14 +822,7 @@ export async function sseWorkspaceLiveStream(
761
822
  }
762
823
  })();
763
824
 
764
- return new Response(channel.stream, {
765
- headers: {
766
- "Content-Type": "text/event-stream; charset=utf-8",
767
- "Cache-Control": "no-cache, no-transform",
768
- Connection: "keep-alive",
769
- ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
770
- },
771
- });
825
+ return await sseHttpResponse(channel.stream, options);
772
826
  }
773
827
 
774
828
  /**
@@ -784,6 +838,28 @@ export async function sseWorkspaceInteractionRevisionStream(
784
838
  signal: AbortSignal,
785
839
  options: WorkspaceInteractionSseOptions = {},
786
840
  ): Promise<Response> {
841
+ if (isHttp1BrowserBatch(options)) {
842
+ const state = await getWorkspaceInteractionRevisionState(db, {
843
+ accountId,
844
+ workspaceId,
845
+ });
846
+ await options.reauthorize?.();
847
+ const frames =
848
+ state.revision > after
849
+ ? [
850
+ formatWorkspaceInteractionRevisionSse(
851
+ WorkspaceInteractionRevisionEvent.parse({
852
+ workspaceId,
853
+ sequence: state.revision,
854
+ revision: state.revision,
855
+ type: "workspace.interaction.changed",
856
+ occurredAt: (state.updatedAt ?? new Date()).toISOString(),
857
+ }),
858
+ ),
859
+ ]
860
+ : [];
861
+ return finiteSseBatchResponse(frames, options);
862
+ }
787
863
  const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
788
864
  const pollIntervalMs = resolveInteractionPollInterval(options.pollIntervalMs);
789
865
  let lastSent = after;
@@ -793,6 +869,7 @@ export async function sseWorkspaceInteractionRevisionStream(
793
869
  let detachAbortListener = () => {};
794
870
  let closeMetrics = () => {};
795
871
  const channel = createByteBoundedSseStream({
872
+ connectionLifetimeMs: options.connectionLifetimeMs,
796
873
  maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
797
874
  ...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
798
875
  onObservation: sseObservationReporter("workspace_interaction", options),
@@ -804,12 +881,10 @@ export async function sseWorkspaceInteractionRevisionStream(
804
881
  },
805
882
  });
806
883
  closeMetrics = observeSseConnection("workspace_interaction", after, options.observability);
807
- stopReauthorization = startSseReauthorization(options, channel.stopped, (error) => {
808
- channel.fail(retryableSseFailure("workspace interaction stream authorization failed", error));
809
- });
884
+ stopReauthorization = startSseReauthorization(options, channel.stopped, channel.close);
810
885
 
811
886
  const write = async (frame: string): Promise<boolean> => {
812
- await options.reauthorize?.();
887
+ await reauthorizeSseOrClose(options, channel);
813
888
  const accepted = await channel.write(frame);
814
889
  if (accepted) lastWriteAt = Date.now();
815
890
  return accepted;
@@ -820,7 +895,10 @@ export async function sseWorkspaceInteractionRevisionStream(
820
895
  if (!(await write(": connected\n\n"))) return;
821
896
  for (;;) {
822
897
  if (stopRequested || signal.aborted || channel.stopped()) return;
823
- const state = await getWorkspaceInteractionRevisionState(db, { accountId, workspaceId });
898
+ const state = await getWorkspaceInteractionRevisionState(db, {
899
+ accountId,
900
+ workspaceId,
901
+ });
824
902
  if (state.revision > lastSent) {
825
903
  const event = WorkspaceInteractionRevisionEvent.parse({
826
904
  workspaceId,
@@ -850,14 +928,7 @@ export async function sseWorkspaceInteractionRevisionStream(
850
928
  detachAbortListener = () => signal.removeEventListener("abort", abort);
851
929
  }
852
930
 
853
- return new Response(channel.stream, {
854
- headers: {
855
- "Content-Type": "text/event-stream; charset=utf-8",
856
- "Cache-Control": "no-cache, no-transform",
857
- Connection: "keep-alive",
858
- ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
859
- },
860
- });
931
+ return await sseHttpResponse(channel.stream, options);
861
932
  }
862
933
 
863
934
  function observeSseConnection(
@@ -918,7 +989,30 @@ async function replayWorkspaceControlEvents(
918
989
 
919
990
  class SseStreamStoppedError extends Error {}
920
991
 
992
+ async function reauthorizeSseOrClose(
993
+ options: SseDeliveryOptions,
994
+ channel: ByteBoundedSseStream,
995
+ ): Promise<void> {
996
+ try {
997
+ await options.reauthorize?.();
998
+ } catch {
999
+ // Authorization loss is an expected fail-closed terminal condition, not a
1000
+ // delivery fault. End the HTTP response cleanly so Bun and Chromium retire
1001
+ // the HTTP/1 socket even if the initiating document is being replaced.
1002
+ // No frame crosses the failed check; a still-live SDK may reconnect and is
1003
+ // fenced again at request admission, while a destroyed realm cannot leave
1004
+ // an errored response occupying the per-origin connection pool.
1005
+ channel.close();
1006
+ throw new SseStreamStoppedError();
1007
+ }
1008
+ }
1009
+
921
1010
  export type SseDeliveryOptions = {
1011
+ connectionLifetimeMs?: number | undefined;
1012
+ /** Return a known-length batch instead of a chunked response. HTTP/1 only. */
1013
+ finiteResponseMaxBytes?: number | undefined;
1014
+ /** Browser transport classification for a finite response. */
1015
+ finiteResponseMediaType?: "event-stream" | "http1-browser-batch" | undefined;
922
1016
  maxQueuedBytes?: number;
923
1017
  stallTimeoutMs?: number;
924
1018
  heartbeatIntervalMs?: number;
@@ -931,6 +1025,146 @@ export type SseDeliveryOptions = {
931
1025
  actorEpoch?: string | undefined;
932
1026
  };
933
1027
 
1028
+ export function browserSseDeliveryOptions(
1029
+ transport: string | undefined,
1030
+ ): Pick<
1031
+ SseDeliveryOptions,
1032
+ "connectionLifetimeMs" | "finiteResponseMaxBytes" | "finiteResponseMediaType"
1033
+ > {
1034
+ return transport === "http1-bounded"
1035
+ ? {
1036
+ finiteResponseMaxBytes: HTTP1_BROWSER_SSE_BATCH_MAX_BYTES,
1037
+ finiteResponseMediaType: "http1-browser-batch",
1038
+ }
1039
+ : {};
1040
+ }
1041
+
1042
+ async function sseHttpResponse(
1043
+ stream: ReadableStream<Uint8Array>,
1044
+ options: SseDeliveryOptions,
1045
+ ): Promise<Response> {
1046
+ const maxBytes = options.finiteResponseMaxBytes;
1047
+ let body: ReadableStream<Uint8Array> | ArrayBuffer = stream;
1048
+ let contentLength: number | null = null;
1049
+ if (maxBytes !== undefined) {
1050
+ if (
1051
+ !Number.isSafeInteger(maxBytes) ||
1052
+ maxBytes < SESSION_EVENT_SSE_FRAME_MAX_BYTES ||
1053
+ maxBytes > HTTP1_BROWSER_SSE_BATCH_MAX_BYTES
1054
+ ) {
1055
+ throw new RangeError(
1056
+ `finite SSE batch limit must be between ${SESSION_EVENT_SSE_FRAME_MAX_BYTES} and ${HTTP1_BROWSER_SSE_BATCH_MAX_BYTES} bytes`,
1057
+ );
1058
+ }
1059
+ body = await collectFiniteSseBatch(stream, maxBytes);
1060
+ contentLength = body.byteLength;
1061
+ }
1062
+ return new Response(body, {
1063
+ headers: {
1064
+ // A bounded HTTP/1 poll carries the same SSE-framed bytes the SDK
1065
+ // already parses, but it is an ordinary finite response at the browser
1066
+ // transport boundary. Keeping `text/event-stream` here lets Chromium
1067
+ // retain an orphaned fetch in its shared per-origin SSE pool after the
1068
+ // initiating document is replaced, starving unrelated finite reads in
1069
+ // every tab. HTTP/2 and other unbounded streams keep the standard media
1070
+ // type; only the explicit `http1-bounded` fallback uses this vendor type.
1071
+ "Content-Type":
1072
+ contentLength !== null && options.finiteResponseMediaType === "http1-browser-batch"
1073
+ ? `${HTTP1_BROWSER_SSE_BATCH_CONTENT_TYPE}; charset=utf-8`
1074
+ : "text/event-stream; charset=utf-8",
1075
+ "Cache-Control": "no-cache, no-transform",
1076
+ // A known-length response is already terminal and leaves the HTTP/1
1077
+ // socket reusable. Only a genuinely live SSE response needs an explicit
1078
+ // close when its stream ends.
1079
+ ...(contentLength === null
1080
+ ? { Connection: "close" }
1081
+ : { "Content-Length": String(contentLength) }),
1082
+ ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
1083
+ },
1084
+ });
1085
+ }
1086
+
1087
+ function isHttp1BrowserBatch(options: SseDeliveryOptions): boolean {
1088
+ return (
1089
+ options.finiteResponseMediaType === "http1-browser-batch" &&
1090
+ options.finiteResponseMaxBytes !== undefined
1091
+ );
1092
+ }
1093
+
1094
+ function finiteSseBatchResponse(frames: readonly string[], options: SseDeliveryOptions): Response {
1095
+ const maxBytes = options.finiteResponseMaxBytes;
1096
+ if (
1097
+ maxBytes === undefined ||
1098
+ !Number.isSafeInteger(maxBytes) ||
1099
+ maxBytes < SESSION_EVENT_SSE_FRAME_MAX_BYTES ||
1100
+ maxBytes > HTTP1_BROWSER_SSE_BATCH_MAX_BYTES
1101
+ ) {
1102
+ throw new RangeError(
1103
+ `finite SSE batch limit must be between ${SESSION_EVENT_SSE_FRAME_MAX_BYTES} and ${HTTP1_BROWSER_SSE_BATCH_MAX_BYTES} bytes`,
1104
+ );
1105
+ }
1106
+ const encoder = new TextEncoder();
1107
+ const chunks: Uint8Array[] = [];
1108
+ let length = 0;
1109
+ for (const frame of frames) {
1110
+ const chunk = encoder.encode(frame);
1111
+ if (chunk.byteLength > maxBytes) {
1112
+ throw new RangeError(
1113
+ `SSE frame cannot fit in the finite browser batch (${chunk.byteLength} > ${maxBytes} bytes)`,
1114
+ );
1115
+ }
1116
+ if (length + chunk.byteLength > maxBytes) break;
1117
+ chunks.push(chunk);
1118
+ length += chunk.byteLength;
1119
+ }
1120
+ const body = new Uint8Array(length);
1121
+ let offset = 0;
1122
+ for (const chunk of chunks) {
1123
+ body.set(chunk, offset);
1124
+ offset += chunk.byteLength;
1125
+ }
1126
+ return new Response(body, {
1127
+ headers: {
1128
+ "Content-Type": `${HTTP1_BROWSER_SSE_BATCH_CONTENT_TYPE}; charset=utf-8`,
1129
+ "Cache-Control": "no-cache, no-transform",
1130
+ "Content-Length": String(body.byteLength),
1131
+ ...(options.actorEpoch ? { [MANAGED_AUTH_ACTOR_EPOCH_HEADER]: options.actorEpoch } : {}),
1132
+ },
1133
+ });
1134
+ }
1135
+
1136
+ async function collectFiniteSseBatch(
1137
+ stream: ReadableStream<Uint8Array>,
1138
+ maxBytes: number,
1139
+ ): Promise<ArrayBuffer> {
1140
+ const reader = stream.getReader();
1141
+ const chunks: Uint8Array[] = [];
1142
+ let length = 0;
1143
+ try {
1144
+ for (;;) {
1145
+ const next = await reader.read();
1146
+ if (next.done) break;
1147
+ // Each source chunk is one complete SSE frame. End before an overflowing
1148
+ // frame so reconnect replay starts from the consumer's last whole cursor.
1149
+ if (length + next.value.byteLength > maxBytes) {
1150
+ await reader.cancel("finite SSE batch reached its byte limit");
1151
+ break;
1152
+ }
1153
+ chunks.push(next.value);
1154
+ length += next.value.byteLength;
1155
+ }
1156
+ } finally {
1157
+ reader.releaseLock();
1158
+ }
1159
+ const bytes = new Uint8Array(length);
1160
+ let offset = 0;
1161
+ for (const chunk of chunks) {
1162
+ bytes.set(chunk, offset);
1163
+ offset += chunk.byteLength;
1164
+ }
1165
+ return bytes.buffer;
1166
+ }
1167
+
934
1168
  export type SessionSseDeliveryOptions = SseDeliveryOptions;
935
1169
 
936
1170
  function sseObservationReporter(
@@ -22,6 +22,7 @@ import {
22
22
  loadIntegrationOAuthClient,
23
23
  normalizeBearerScheme,
24
24
  replaceIntegrationOAuthClientIfCurrent,
25
+ resolveNamedManagedPersonalWorkspaceGrant,
25
26
  storeIntegrationOAuthClient,
26
27
  updateConnection,
27
28
  withDatabaseStatementTimeout,
@@ -792,7 +793,13 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
792
793
  }
793
794
 
794
795
  async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
795
- const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
796
+ const membershipGrant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
797
+ const grant =
798
+ membershipGrant?.accountId === state.accountId
799
+ ? membershipGrant
800
+ : state.personalOwnerVerified
801
+ ? await resolveNamedManagedPersonalWorkspaceGrant(db, state)
802
+ : null;
796
803
  if (
797
804
  !grant ||
798
805
  grant.accountId !== state.accountId ||
@@ -27,6 +27,7 @@ import {
27
27
  getWorkspaceGrant,
28
28
  loadConnectionCredentialForBroker,
29
29
  persistProviderOAuthConnection,
30
+ resolveNamedManagedPersonalWorkspaceGrant,
30
31
  } from "@opengeni/db";
31
32
  import { createSignedState, readSignedState } from "@opengeni/github";
32
33
  import {
@@ -739,9 +740,18 @@ async function providerFetch(
739
740
 
740
741
  async function requireProviderOAuthGrant(
741
742
  deps: ApiRouteDeps,
742
- state: Pick<ProviderOAuthState, "accountId" | "workspaceId" | "subjectId">,
743
+ state: Pick<
744
+ ProviderOAuthState,
745
+ "accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified"
746
+ >,
743
747
  ): Promise<void> {
744
- const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
748
+ const membershipGrant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
749
+ const grant =
750
+ membershipGrant?.accountId === state.accountId
751
+ ? membershipGrant
752
+ : state.personalOwnerVerified
753
+ ? await resolveNamedManagedPersonalWorkspaceGrant(deps.db, state)
754
+ : null;
745
755
  if (
746
756
  !grant ||
747
757
  grant.accountId !== state.accountId ||
@@ -16,6 +16,7 @@ import {
16
16
  interactionOperationMetricObserver,
17
17
  type Observability,
18
18
  } from "@opengeni/observability";
19
+ import type { ComputerFrameEvidenceMismatchReason } from "@opengeni/runtime/sandbox";
19
20
 
20
21
  type InteractionActionReceipt = BrowserActionReceipt | ComputerActionReceipt;
21
22
  type InteractionLifecycleMutation =
@@ -32,6 +33,14 @@ const STALE_INTERACTION_ERROR_CODES = new Set([
32
33
  "attempt_stale",
33
34
  ]);
34
35
 
36
+ const COMPUTER_FRAME_EVIDENCE_MISMATCH_REASONS = new Set<ComputerFrameEvidenceMismatchReason>([
37
+ "frame_session_mismatch",
38
+ "frame_target_mismatch",
39
+ "frame_controller_mismatch",
40
+ "frame_media_mismatch",
41
+ "frame_digest_mismatch",
42
+ ]);
43
+
35
44
  export function observeBrowserActionResult(
36
45
  observability: Observability | null | undefined,
37
46
  startedAtMs: number,
@@ -62,6 +71,27 @@ export function observeComputerActionResult(
62
71
  });
63
72
  }
64
73
 
74
+ export function observeComputerFrameEvidenceMismatch(
75
+ observability: Observability | null | undefined,
76
+ reason: ComputerFrameEvidenceMismatchReason,
77
+ ): void {
78
+ if (!observability || !COMPUTER_FRAME_EVIDENCE_MISMATCH_REASONS.has(reason)) return;
79
+ try {
80
+ observability.incrementCounter({
81
+ name: "opengeni_computer_frame_evidence_mismatches_total",
82
+ help: "Computer frame evidence rejected at the controller-to-API boundary by bounded reason.",
83
+ labels: { reason },
84
+ });
85
+ } catch {
86
+ // Observability cannot alter the fail-closed frame boundary.
87
+ }
88
+ try {
89
+ observability.warn("Computer frame evidence mismatch", { reason });
90
+ } catch {
91
+ // Observability cannot alter the fail-closed frame boundary.
92
+ }
93
+ }
94
+
65
95
  export function observeLifecycleResult(
66
96
  observability: Observability | null | undefined,
67
97
  startedAtMs: number,