@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.
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
- package/dist/auth/managed-auth.d.ts +24 -1
- package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
- package/dist/chunk-XTLI3CBH.js.map +1 -0
- package/dist/http/sse.d.ts +9 -0
- package/dist/index.js +104 -4
- package/dist/index.js.map +1 -1
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
- package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
- package/dist/mcp/remember.d.ts +2 -2
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/mcp/session-wait.d.ts +19 -0
- package/dist/routes/api-keys.d.ts +2 -0
- package/dist/routes/browser-sessions.d.ts +1 -0
- package/dist/routes/computer-sessions.d.ts +12 -0
- package/dist/routes/managed-auth-session-sets.d.ts +7 -0
- package/dist/routes/workspaces.d.ts +1 -1
- package/dist/sandbox/metrics-ingestion.d.ts +16 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/package.json +15 -15
- package/src/app.ts +172 -20
- package/src/auth/managed-auth-attempt-context.ts +40 -3
- package/src/auth/managed-auth-session-adapter.ts +1 -0
- package/src/auth/managed-auth.ts +164 -4
- package/src/http/sse.ts +279 -45
- package/src/integrations/oauth-client.ts +8 -1
- package/src/integrations/provider-oauth.ts +12 -2
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/company-brain-governed-writes.ts +38 -23
- package/src/mcp/company-profile-agent-admin.ts +7 -7
- package/src/mcp/remember.ts +19 -8
- package/src/mcp/server.ts +318 -26
- package/src/mcp/session-wait.ts +56 -8
- package/src/routes/api-integrations.ts +2 -2
- package/src/routes/api-keys.ts +149 -7
- package/src/routes/browser-sessions.ts +10 -2
- package/src/routes/capabilities.ts +3 -3
- package/src/routes/codex.ts +483 -74
- package/src/routes/company-profile.ts +64 -0
- package/src/routes/computer-sessions.ts +103 -1
- package/src/routes/integration-facets.ts +8 -5
- package/src/routes/interaction-resources.ts +7 -1
- package/src/routes/managed-auth-session-sets.ts +199 -2
- package/src/routes/organization-memberships.ts +28 -8
- package/src/routes/packs.ts +5 -5
- package/src/routes/plugins.ts +2 -2
- package/src/routes/scheduled-tasks.ts +9 -0
- package/src/routes/sessions.ts +3 -6
- package/src/routes/skills.ts +3 -3
- package/src/routes/workspaces.ts +293 -54
- package/src/sandbox/channel-a.ts +10 -4
- package/src/sandbox/machines.ts +13 -6
- package/src/sandbox/metrics-ingestion.ts +157 -3
- package/src/sandbox/viewer.ts +20 -1
- package/src/workspace-delete-observability.ts +75 -0
- 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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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<
|
|
743
|
+
state: Pick<
|
|
744
|
+
ProviderOAuthState,
|
|
745
|
+
"accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified"
|
|
746
|
+
>,
|
|
743
747
|
): Promise<void> {
|
|
744
|
-
const
|
|
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,
|