@opengeni/api-router 0.21.14 → 0.23.1
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.d.ts +1 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +0 -30
- package/dist/{chunk-R5PDSH2A.js → chunk-T4T2PGU4.js} +3989 -1356
- package/dist/chunk-T4T2PGU4.js.map +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.js +29 -9
- package/dist/index.js.map +1 -1
- package/dist/integrations/oauth-client.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +7 -1
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +350 -0
- package/dist/mcp/toolspace.d.ts +9 -0
- package/dist/routes/transcription-recordings.d.ts +3 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +5 -1
- package/dist/transcription/segmenter.d.ts +10 -0
- package/dist/transcription/service.d.ts +5 -0
- package/package.json +12 -12
- package/src/app.ts +39 -6
- package/src/auth/managed-auth.ts +0 -16
- package/src/http/sse.ts +101 -6
- package/src/index.ts +28 -3
- package/src/integrations/oauth-client.ts +36 -56
- package/src/integrations/slack-interactions.ts +123 -15
- package/src/mcp/documents.ts +42 -25
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +608 -0
- package/src/mcp/server.ts +812 -182
- package/src/mcp/toolspace.ts +75 -71
- package/src/observability.ts +3 -3
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/codex.ts +7 -4
- package/src/routes/connections.ts +74 -3
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +175 -65
- package/src/routes/install.ts +31 -1
- package/src/routes/machines.ts +1 -1
- package/src/routes/scheduled-tasks.ts +39 -14
- package/src/routes/sessions.ts +77 -12
- package/src/routes/transcription-recordings.ts +754 -0
- package/src/routes/transcriptions.ts +2 -0
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +124 -7
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/viewer.ts +29 -20
- package/src/transcription/providers/azure-openai.ts +4 -3
- package/src/transcription/providers/codex-subscription.ts +4 -1
- package/src/transcription/providers/openai.ts +7 -2
- package/src/transcription/segmenter.ts +260 -0
- package/src/transcription/service.ts +111 -10
- package/dist/chunk-R5PDSH2A.js.map +0 -1
package/src/http/sse.ts
CHANGED
|
@@ -12,6 +12,11 @@ const SESSION_REPLAY_PAGE_SIZE = 100;
|
|
|
12
12
|
const WORKSPACE_CONTROL_REPLAY_PAGE_SIZE = 100;
|
|
13
13
|
export const SSE_QUEUED_FRAME_MAX_COUNT = 1;
|
|
14
14
|
export const SSE_WRITE_STALL_TIMEOUT_MS = 30_000;
|
|
15
|
+
export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
16
|
+
const activeSseStreams: Record<"session" | "workspace_control", number> = {
|
|
17
|
+
session: 0,
|
|
18
|
+
workspace_control: 0,
|
|
19
|
+
};
|
|
15
20
|
|
|
16
21
|
export type SseDeliveryBoundObservation = {
|
|
17
22
|
reason: "desired_size_non_positive" | "stall_timeout" | "frame_too_large";
|
|
@@ -251,6 +256,7 @@ export async function sseSessionStream(
|
|
|
251
256
|
signal: AbortSignal,
|
|
252
257
|
options: SessionSseDeliveryOptions = {},
|
|
253
258
|
): Promise<Response> {
|
|
259
|
+
const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
|
|
254
260
|
if (
|
|
255
261
|
options.reauthorize &&
|
|
256
262
|
options.reauthorizeAfterMs !== undefined &&
|
|
@@ -266,13 +272,20 @@ export async function sseSessionStream(
|
|
|
266
272
|
let unsubscribe: (() => void) | null = null;
|
|
267
273
|
let delivery: LatestWinsDelivery<SessionEvent> | null = null;
|
|
268
274
|
let reauthorizationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
275
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
269
276
|
let detachAbortListener = () => {};
|
|
277
|
+
let closeMetrics = () => {};
|
|
270
278
|
const stopUpstream = () => {
|
|
279
|
+
closeMetrics();
|
|
271
280
|
detachAbortListener();
|
|
272
281
|
if (reauthorizationTimer) {
|
|
273
282
|
clearTimeout(reauthorizationTimer);
|
|
274
283
|
reauthorizationTimer = null;
|
|
275
284
|
}
|
|
285
|
+
if (heartbeatTimer) {
|
|
286
|
+
clearTimeout(heartbeatTimer);
|
|
287
|
+
heartbeatTimer = null;
|
|
288
|
+
}
|
|
276
289
|
delivery?.stop();
|
|
277
290
|
const release = unsubscribe;
|
|
278
291
|
unsubscribe = null;
|
|
@@ -284,6 +297,7 @@ export async function sseSessionStream(
|
|
|
284
297
|
onObservation: sseObservationReporter("session", options),
|
|
285
298
|
onStop: stopUpstream,
|
|
286
299
|
});
|
|
300
|
+
closeMetrics = observeSseConnection("session", after, options.observability);
|
|
287
301
|
|
|
288
302
|
const fail = (error: unknown) => {
|
|
289
303
|
channel.fail(retryableSseFailure("session event stream delivery failed", error));
|
|
@@ -299,10 +313,24 @@ export async function sseSessionStream(
|
|
|
299
313
|
}, interval);
|
|
300
314
|
};
|
|
301
315
|
scheduleReauthorization();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
316
|
+
let writeTail = Promise.resolve();
|
|
317
|
+
const writeFrame = (frame: string): Promise<void> => {
|
|
318
|
+
const write = writeTail.then(async () => {
|
|
319
|
+
if (!(await channel.write(frame))) throw new SseStreamStoppedError();
|
|
320
|
+
});
|
|
321
|
+
writeTail = write.catch(() => {});
|
|
322
|
+
return write;
|
|
323
|
+
};
|
|
324
|
+
const scheduleHeartbeat = () => {
|
|
325
|
+
if (channel.stopped()) return;
|
|
326
|
+
heartbeatTimer = setTimeout(() => {
|
|
327
|
+
heartbeatTimer = null;
|
|
328
|
+
void writeFrame(": heartbeat\n\n")
|
|
329
|
+
.then(scheduleHeartbeat)
|
|
330
|
+
.catch((error) => {
|
|
331
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
332
|
+
});
|
|
333
|
+
}, heartbeatIntervalMs);
|
|
306
334
|
};
|
|
307
335
|
const send = async (event: SessionEvent) => {
|
|
308
336
|
if (event.sequence <= lastSent) return;
|
|
@@ -362,6 +390,7 @@ export async function sseSessionStream(
|
|
|
362
390
|
SESSION_REPLAY_PAGE_SIZE,
|
|
363
391
|
);
|
|
364
392
|
await writeFrame(": connected\n\n");
|
|
393
|
+
scheduleHeartbeat();
|
|
365
394
|
bootstrapping = false;
|
|
366
395
|
const buffered = newestBuffered;
|
|
367
396
|
newestBuffered = null;
|
|
@@ -425,14 +454,22 @@ export async function sseWorkspaceControlStream(
|
|
|
425
454
|
signal: AbortSignal,
|
|
426
455
|
options: SseDeliveryOptions = {},
|
|
427
456
|
): Promise<Response> {
|
|
457
|
+
const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
|
|
428
458
|
let lastSent = after;
|
|
429
459
|
let bootstrapping = true;
|
|
430
460
|
let newestBuffered: WorkspaceControlEvent | null = null;
|
|
431
461
|
let unsubscribe: (() => void) | null = null;
|
|
432
462
|
let delivery: LatestWinsDelivery<WorkspaceControlEvent> | null = null;
|
|
463
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
433
464
|
let detachAbortListener = () => {};
|
|
465
|
+
let closeMetrics = () => {};
|
|
434
466
|
const stopUpstream = () => {
|
|
467
|
+
closeMetrics();
|
|
435
468
|
detachAbortListener();
|
|
469
|
+
if (heartbeatTimer) {
|
|
470
|
+
clearTimeout(heartbeatTimer);
|
|
471
|
+
heartbeatTimer = null;
|
|
472
|
+
}
|
|
436
473
|
delivery?.stop();
|
|
437
474
|
const release = unsubscribe;
|
|
438
475
|
unsubscribe = null;
|
|
@@ -444,12 +481,29 @@ export async function sseWorkspaceControlStream(
|
|
|
444
481
|
onObservation: sseObservationReporter("workspace_control", options),
|
|
445
482
|
onStop: stopUpstream,
|
|
446
483
|
});
|
|
484
|
+
closeMetrics = observeSseConnection("workspace_control", after, options.observability);
|
|
447
485
|
|
|
448
486
|
const fail = (error: unknown) => {
|
|
449
487
|
channel.fail(retryableSseFailure("workspace control stream delivery failed", error));
|
|
450
488
|
};
|
|
451
|
-
|
|
452
|
-
|
|
489
|
+
let writeTail = Promise.resolve();
|
|
490
|
+
const writeFrame = (frame: string): Promise<void> => {
|
|
491
|
+
const write = writeTail.then(async () => {
|
|
492
|
+
if (!(await channel.write(frame))) throw new SseStreamStoppedError();
|
|
493
|
+
});
|
|
494
|
+
writeTail = write.catch(() => {});
|
|
495
|
+
return write;
|
|
496
|
+
};
|
|
497
|
+
const scheduleHeartbeat = () => {
|
|
498
|
+
if (channel.stopped()) return;
|
|
499
|
+
heartbeatTimer = setTimeout(() => {
|
|
500
|
+
heartbeatTimer = null;
|
|
501
|
+
void writeFrame(": heartbeat\n\n")
|
|
502
|
+
.then(scheduleHeartbeat)
|
|
503
|
+
.catch((error) => {
|
|
504
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
505
|
+
});
|
|
506
|
+
}, heartbeatIntervalMs);
|
|
453
507
|
};
|
|
454
508
|
const send = async (event: WorkspaceControlEvent) => {
|
|
455
509
|
if (event.sequence <= lastSent) return;
|
|
@@ -505,6 +559,7 @@ export async function sseWorkspaceControlStream(
|
|
|
505
559
|
WORKSPACE_CONTROL_REPLAY_PAGE_SIZE,
|
|
506
560
|
);
|
|
507
561
|
await writeFrame(": connected\n\n");
|
|
562
|
+
scheduleHeartbeat();
|
|
508
563
|
bootstrapping = false;
|
|
509
564
|
const buffered = newestBuffered;
|
|
510
565
|
newestBuffered = null;
|
|
@@ -531,6 +586,37 @@ export async function sseWorkspaceControlStream(
|
|
|
531
586
|
});
|
|
532
587
|
}
|
|
533
588
|
|
|
589
|
+
function observeSseConnection(
|
|
590
|
+
stream: "session" | "workspace_control",
|
|
591
|
+
after: number,
|
|
592
|
+
observability: Observability | undefined,
|
|
593
|
+
): () => void {
|
|
594
|
+
activeSseStreams[stream] += 1;
|
|
595
|
+
observability?.incrementCounter({
|
|
596
|
+
name: "opengeni_sse_connections_total",
|
|
597
|
+
help: "SSE connections opened at the API boundary.",
|
|
598
|
+
labels: { stream, resume: after > 0 ? "resumed" : "fresh" },
|
|
599
|
+
});
|
|
600
|
+
observability?.setGauge?.({
|
|
601
|
+
name: "opengeni_sse_connections_active",
|
|
602
|
+
help: "Currently active SSE connections at the API boundary.",
|
|
603
|
+
labels: { stream },
|
|
604
|
+
value: activeSseStreams[stream],
|
|
605
|
+
});
|
|
606
|
+
let closed = false;
|
|
607
|
+
return () => {
|
|
608
|
+
if (closed) return;
|
|
609
|
+
closed = true;
|
|
610
|
+
activeSseStreams[stream] = Math.max(0, activeSseStreams[stream] - 1);
|
|
611
|
+
observability?.setGauge?.({
|
|
612
|
+
name: "opengeni_sse_connections_active",
|
|
613
|
+
help: "Currently active SSE connections at the API boundary.",
|
|
614
|
+
labels: { stream },
|
|
615
|
+
value: activeSseStreams[stream],
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
534
620
|
async function replayWorkspaceControlEvents(
|
|
535
621
|
loadPage: (after: number, limit: number) => Promise<WorkspaceControlEvent[]>,
|
|
536
622
|
send: (event: WorkspaceControlEvent) => Promise<void>,
|
|
@@ -561,6 +647,7 @@ class SseStreamStoppedError extends Error {}
|
|
|
561
647
|
export type SseDeliveryOptions = {
|
|
562
648
|
maxQueuedBytes?: number;
|
|
563
649
|
stallTimeoutMs?: number;
|
|
650
|
+
heartbeatIntervalMs?: number;
|
|
564
651
|
observability?: Observability | undefined;
|
|
565
652
|
onObservation?: ((observation: SseDeliveryBoundObservation) => void) | undefined;
|
|
566
653
|
};
|
|
@@ -597,3 +684,11 @@ function sseObservationReporter(
|
|
|
597
684
|
function retryableSseFailure(message: string, error: unknown): TypeError {
|
|
598
685
|
return error instanceof TypeError ? error : new TypeError(message, { cause: error });
|
|
599
686
|
}
|
|
687
|
+
|
|
688
|
+
function resolveHeartbeatInterval(value: number | undefined): number {
|
|
689
|
+
const interval = value ?? SSE_HEARTBEAT_INTERVAL_MS;
|
|
690
|
+
if (!Number.isSafeInteger(interval) || interval < 1_000 || interval > 60_000) {
|
|
691
|
+
throw new RangeError("SSE heartbeat interval must be between 1000 and 60000ms");
|
|
692
|
+
}
|
|
693
|
+
return interval;
|
|
694
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -366,11 +366,13 @@ export async function startApi() {
|
|
|
366
366
|
{ db: dbClient.db, settings, callout, observability },
|
|
367
367
|
settings.natsUrl,
|
|
368
368
|
);
|
|
369
|
-
} catch
|
|
369
|
+
} catch {
|
|
370
370
|
// A responder start failure must not crash the API (other planes work); log
|
|
371
371
|
// loudly — selfhosted agents will fail to connect until it is up.
|
|
372
372
|
observability.error("OpenGeni NATS auth-callout responder failed to start", {
|
|
373
|
-
|
|
373
|
+
errorClass: "NatsAuthCalloutOperationError",
|
|
374
|
+
errorCode: "nats_auth_callout_start_failed",
|
|
375
|
+
origin: "api",
|
|
374
376
|
});
|
|
375
377
|
}
|
|
376
378
|
} else {
|
|
@@ -422,7 +424,7 @@ export function shouldCreateScheduleAfterUpdateError(error: unknown): boolean {
|
|
|
422
424
|
export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): ScheduleSpec {
|
|
423
425
|
if (schedule.type === "interval") {
|
|
424
426
|
return {
|
|
425
|
-
intervals: [
|
|
427
|
+
intervals: [temporalIntervalSpec(schedule)],
|
|
426
428
|
...(schedule.startAt ? { startAt: new Date(schedule.startAt) } : {}),
|
|
427
429
|
...(schedule.endAt ? { endAt: new Date(schedule.endAt) } : {}),
|
|
428
430
|
};
|
|
@@ -456,6 +458,29 @@ export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): Sched
|
|
|
456
458
|
};
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
function temporalIntervalSpec(
|
|
462
|
+
schedule: Extract<ScheduledTaskScheduleSpec, { type: "interval" }>,
|
|
463
|
+
): NonNullable<ScheduleSpec["intervals"]>[number] {
|
|
464
|
+
const every = `${schedule.everySeconds}s` as `${number}s`;
|
|
465
|
+
if (!schedule.startAt) {
|
|
466
|
+
return { every };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Temporal interval schedules match Epoch + (n * every) + offset. Its
|
|
470
|
+
// top-level startAt only filters matching times before that boundary, so it
|
|
471
|
+
// does not itself anchor the cadence. Derive the phase from startAt to make
|
|
472
|
+
// the stored OpenGeni timestamp the first interval boundary rather than the
|
|
473
|
+
// next epoch-aligned match.
|
|
474
|
+
const everyMilliseconds = BigInt(schedule.everySeconds) * 1_000n;
|
|
475
|
+
const startMilliseconds = BigInt(new Date(schedule.startAt).getTime());
|
|
476
|
+
const offsetMilliseconds =
|
|
477
|
+
((startMilliseconds % everyMilliseconds) + everyMilliseconds) % everyMilliseconds;
|
|
478
|
+
return {
|
|
479
|
+
every,
|
|
480
|
+
...(offsetMilliseconds === 0n ? {} : { offset: `${offsetMilliseconds}ms` as `${number}ms` }),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
459
484
|
function temporalMonth(monthIndex: number) {
|
|
460
485
|
return TEMPORAL_MONTHS[monthIndex]!;
|
|
461
486
|
}
|
|
@@ -282,8 +282,7 @@ export async function startMcpOAuth(
|
|
|
282
282
|
error instanceof OAuthStartStageError
|
|
283
283
|
? error
|
|
284
284
|
: new OAuthStartStageError("connection_lookup", oauthStartFailureReason(error), error);
|
|
285
|
-
|
|
286
|
-
logOAuthStartFailure(deps.observability, staged, providerDomain);
|
|
285
|
+
logOAuthStartFailure(deps.observability, staged);
|
|
287
286
|
throw oauthStartApiError(staged);
|
|
288
287
|
} finally {
|
|
289
288
|
deadline.dispose();
|
|
@@ -1474,31 +1473,16 @@ function throwIfCallbackAborted(signal: AbortSignal, stage: OAuthCallbackStage):
|
|
|
1474
1473
|
function logOAuthCallbackFailure(
|
|
1475
1474
|
observability: Observability | undefined,
|
|
1476
1475
|
error: OAuthCallbackStageError,
|
|
1477
|
-
|
|
1476
|
+
_state: OAuthStatePayload | null,
|
|
1478
1477
|
): void {
|
|
1479
|
-
observability?.error("MCP OAuth callback failed",
|
|
1480
|
-
"opengeni.oauth.stage": error.stage,
|
|
1481
|
-
"opengeni.oauth.reason": error.reason,
|
|
1482
|
-
"opengeni.oauth.provider_domain": state?.providerDomain,
|
|
1483
|
-
"opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
|
|
1484
|
-
"opengeni.oauth.authorization_server": state?.authorizationServer,
|
|
1485
|
-
"opengeni.oauth.issuer": state?.issuer,
|
|
1486
|
-
"opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
|
|
1487
|
-
error: sanitizedError(error.cause),
|
|
1488
|
-
});
|
|
1478
|
+
observability?.error("MCP OAuth callback failed", oauthPublicErrorFields(error.cause));
|
|
1489
1479
|
}
|
|
1490
1480
|
|
|
1491
1481
|
function logOAuthStartFailure(
|
|
1492
1482
|
observability: Observability | undefined,
|
|
1493
1483
|
error: OAuthStartStageError,
|
|
1494
|
-
providerDomain: string | undefined,
|
|
1495
1484
|
): void {
|
|
1496
|
-
observability?.warn("MCP OAuth setup failed",
|
|
1497
|
-
"opengeni.oauth.stage": error.stage,
|
|
1498
|
-
"opengeni.oauth.reason": error.reason,
|
|
1499
|
-
"opengeni.oauth.provider_domain": providerDomain,
|
|
1500
|
-
error: sanitizedError(error.cause),
|
|
1501
|
-
});
|
|
1485
|
+
observability?.warn("MCP OAuth setup failed", oauthPublicErrorFields(error.cause));
|
|
1502
1486
|
}
|
|
1503
1487
|
|
|
1504
1488
|
function oauthStartFailureReason(error: unknown): string {
|
|
@@ -1581,51 +1565,47 @@ function oauthStartStageLabel(stage: OAuthStartStage): string {
|
|
|
1581
1565
|
function logOAuthVerificationWarning(
|
|
1582
1566
|
observability: Observability | undefined,
|
|
1583
1567
|
error: OAuthCallbackStageError,
|
|
1584
|
-
|
|
1568
|
+
_state: OAuthStatePayload,
|
|
1585
1569
|
): void {
|
|
1586
|
-
observability?.warn(
|
|
1587
|
-
"
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
"opengeni.oauth.resource_host": safeHost(state.resource),
|
|
1591
|
-
"opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
|
|
1592
|
-
"opengeni.oauth.authorization_server": state.authorizationServer,
|
|
1593
|
-
"opengeni.oauth.issuer": state.issuer,
|
|
1594
|
-
"opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
|
|
1595
|
-
error: sanitizedError(error.cause),
|
|
1596
|
-
});
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
function sanitizedError(error: unknown): string {
|
|
1600
|
-
if (error instanceof HTTPException) {
|
|
1601
|
-
return `HTTPException ${error.status}: ${error.message}`;
|
|
1602
|
-
}
|
|
1603
|
-
if (error instanceof Error) {
|
|
1604
|
-
return `${error.name}: ${error.message}`;
|
|
1605
|
-
}
|
|
1606
|
-
return String(error);
|
|
1570
|
+
observability?.warn(
|
|
1571
|
+
"MCP OAuth tools/list verification failed after token exchange",
|
|
1572
|
+
oauthPublicErrorFields(error.cause),
|
|
1573
|
+
);
|
|
1607
1574
|
}
|
|
1608
1575
|
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1576
|
+
export type OAuthPublicErrorFields = {
|
|
1577
|
+
errorClass: "OAuthOperationError";
|
|
1578
|
+
errorCode: "oauth_operation_failed";
|
|
1579
|
+
status?: number;
|
|
1580
|
+
origin: "oauth";
|
|
1581
|
+
};
|
|
1612
1582
|
|
|
1613
|
-
|
|
1583
|
+
/** Allowlisted projection for public telemetry; canonical OAuth errors stay exact. */
|
|
1584
|
+
export function oauthPublicErrorFields(error: unknown): OAuthPublicErrorFields {
|
|
1585
|
+
const fields: OAuthPublicErrorFields = {
|
|
1586
|
+
errorClass: "OAuthOperationError",
|
|
1587
|
+
errorCode: "oauth_operation_failed",
|
|
1588
|
+
origin: "oauth",
|
|
1589
|
+
};
|
|
1614
1590
|
try {
|
|
1615
|
-
|
|
1591
|
+
const rawStatus =
|
|
1592
|
+
error instanceof HTTPException
|
|
1593
|
+
? error.status
|
|
1594
|
+
: error && typeof error === "object"
|
|
1595
|
+
? ((error as { status?: unknown; statusCode?: unknown }).status ??
|
|
1596
|
+
(error as { statusCode?: unknown }).statusCode)
|
|
1597
|
+
: undefined;
|
|
1598
|
+
const status = Number(rawStatus);
|
|
1599
|
+
if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status;
|
|
1616
1600
|
} catch {
|
|
1617
|
-
|
|
1601
|
+
// Public telemetry is best-effort. A hostile getter/proxy must never
|
|
1602
|
+
// replace the exact OAuth failure with a projection failure.
|
|
1618
1603
|
}
|
|
1604
|
+
return fields;
|
|
1619
1605
|
}
|
|
1620
1606
|
|
|
1621
|
-
function
|
|
1622
|
-
|
|
1623
|
-
const resource = payload.mcpUrl ?? payload.resource;
|
|
1624
|
-
if (!resource) return undefined;
|
|
1625
|
-
return canonicalProviderDomain(payload.providerDomain ?? new URL(resource).hostname);
|
|
1626
|
-
} catch {
|
|
1627
|
-
return undefined;
|
|
1628
|
-
}
|
|
1607
|
+
function errorMessage(error: unknown): string {
|
|
1608
|
+
return error instanceof Error ? error.message : String(error);
|
|
1629
1609
|
}
|
|
1630
1610
|
|
|
1631
1611
|
async function oauthErrorFromResponse(
|
|
@@ -120,6 +120,31 @@ export type NormalizedSlackInteraction = {
|
|
|
120
120
|
text: string;
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
export function slackInteractionRoutePolicy(
|
|
124
|
+
entry: Pick<
|
|
125
|
+
SlackInteractionInboxEntry,
|
|
126
|
+
"triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
|
|
127
|
+
>,
|
|
128
|
+
) {
|
|
129
|
+
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
130
|
+
const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
|
|
131
|
+
return {
|
|
132
|
+
directMessageShortcut,
|
|
133
|
+
requiresChannelAccess: !directMessageShortcut,
|
|
134
|
+
visibility:
|
|
135
|
+
entry.triggerKind === "dm" || directMessageShortcut
|
|
136
|
+
? ("private" as const)
|
|
137
|
+
: ("workspace" as const),
|
|
138
|
+
// A human-to-human DM may be shared by multiple linked workspace users. The
|
|
139
|
+
// signed shortcut authorizes only the invoking user, so the pre-ack route
|
|
140
|
+
// must keep each user's private reservation distinct until it is rekeyed to
|
|
141
|
+
// that user's OpenGeni bot-DM thread.
|
|
142
|
+
initialRouteKey: directMessageShortcut
|
|
143
|
+
? `${source}:shortcut-user:${entry.slackUserId}`
|
|
144
|
+
: source,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
export function verifySlackRequestSignature(
|
|
124
149
|
input: {
|
|
125
150
|
timestamp: string | null;
|
|
@@ -566,7 +591,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
566
591
|
await processSlackReactionInboxEntry(deps, entry);
|
|
567
592
|
return;
|
|
568
593
|
}
|
|
569
|
-
const
|
|
594
|
+
const routePolicy = slackInteractionRoutePolicy(entry);
|
|
595
|
+
const routeKey = routePolicy.initialRouteKey;
|
|
570
596
|
const existing = await getSlackInteractionByRoute(
|
|
571
597
|
deps.db,
|
|
572
598
|
entry.workspaceId,
|
|
@@ -588,7 +614,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
588
614
|
subjectId: link?.subjectId ?? "service:slack-interaction",
|
|
589
615
|
...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
|
|
590
616
|
});
|
|
591
|
-
|
|
617
|
+
if (routePolicy.requiresChannelAccess) {
|
|
618
|
+
await client.verifyChannelAccess(entry.slackChannelId);
|
|
619
|
+
}
|
|
592
620
|
if (!link) {
|
|
593
621
|
await client.postMessage({
|
|
594
622
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
@@ -604,6 +632,47 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
604
632
|
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
605
633
|
}
|
|
606
634
|
|
|
635
|
+
const alreadyDurable = await getSlackInteractionByClientEventId(
|
|
636
|
+
deps.db,
|
|
637
|
+
entry.workspaceId,
|
|
638
|
+
entry.connectionId,
|
|
639
|
+
`slack:${entry.providerEventId}`,
|
|
640
|
+
);
|
|
641
|
+
if (alreadyDurable) {
|
|
642
|
+
const { interaction, eventSessionId } = alreadyDurable;
|
|
643
|
+
if (interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId) {
|
|
644
|
+
throw new SlackInteractionPermanentError("session_owner_mismatch");
|
|
645
|
+
}
|
|
646
|
+
if (interaction.sessionId !== null && interaction.sessionId !== eventSessionId) {
|
|
647
|
+
throw new SlackInteractionPermanentError("slack_interaction_event_conflict");
|
|
648
|
+
}
|
|
649
|
+
const boundInteraction =
|
|
650
|
+
interaction.sessionId !== null
|
|
651
|
+
? interaction
|
|
652
|
+
: await bindSlackInteractionSession(deps.db, {
|
|
653
|
+
...interaction,
|
|
654
|
+
owningSubjectId: grant.subjectId,
|
|
655
|
+
sessionId: eventSessionId,
|
|
656
|
+
});
|
|
657
|
+
if (!boundInteraction) {
|
|
658
|
+
throw new Error("Durable Slack interaction could not bind its reserved session");
|
|
659
|
+
}
|
|
660
|
+
const shouldRepairAcknowledgement =
|
|
661
|
+
interaction.triggeringProviderEventId === entry.providerEventId ||
|
|
662
|
+
(isDirectMessageShortcut(entry) && boundInteraction.ackSlackMessageTs === null);
|
|
663
|
+
if (shouldRepairAcknowledgement) {
|
|
664
|
+
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
665
|
+
accountId: entry.accountId,
|
|
666
|
+
workspaceId: entry.workspaceId,
|
|
667
|
+
connectionId: entry.connectionId,
|
|
668
|
+
subjectId: grant.subjectId,
|
|
669
|
+
sessionId: eventSessionId,
|
|
670
|
+
});
|
|
671
|
+
await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
607
676
|
if (existing?.sessionId) {
|
|
608
677
|
await continueSlackSession(deps, grant, existing, entry);
|
|
609
678
|
return;
|
|
@@ -621,7 +690,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
621
690
|
routeKey,
|
|
622
691
|
triggeringProviderEventId: entry.providerEventId,
|
|
623
692
|
owningSubjectId: grant.subjectId,
|
|
624
|
-
visibility:
|
|
693
|
+
visibility: routePolicy.visibility,
|
|
625
694
|
});
|
|
626
695
|
if (interaction.sessionId) {
|
|
627
696
|
await continueSlackSession(deps, grant, interaction, entry);
|
|
@@ -647,10 +716,14 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
647
716
|
if (error instanceof HTTPException) {
|
|
648
717
|
await client.postMessage({
|
|
649
718
|
operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
719
|
+
...(isDirectMessageShortcut(entry)
|
|
720
|
+
? { userId: entry.slackUserId }
|
|
721
|
+
: {
|
|
722
|
+
channelId: entry.slackChannelId,
|
|
723
|
+
...(entry.triggerKind === "slash_command"
|
|
724
|
+
? {}
|
|
725
|
+
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
726
|
+
}),
|
|
654
727
|
text: slackAdmissionFailureText(error),
|
|
655
728
|
});
|
|
656
729
|
}
|
|
@@ -662,21 +735,50 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
662
735
|
sessionId: session.id,
|
|
663
736
|
});
|
|
664
737
|
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
738
|
+
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
739
|
+
accountId: entry.accountId,
|
|
740
|
+
workspaceId: entry.workspaceId,
|
|
741
|
+
connectionId: entry.connectionId,
|
|
742
|
+
subjectId: grant.subjectId,
|
|
743
|
+
sessionId: session.id,
|
|
744
|
+
});
|
|
745
|
+
await acknowledgeSlackSession(deps, boundClient, bound, entry);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function acknowledgeSlackSession(
|
|
749
|
+
deps: ApiRouteDeps,
|
|
750
|
+
client: OpenGeniSlackBotClient,
|
|
751
|
+
interaction: SlackInteraction,
|
|
752
|
+
entry: SlackInteractionInboxEntry,
|
|
753
|
+
) {
|
|
754
|
+
if (!interaction.sessionId) {
|
|
755
|
+
throw new Error("Slack acknowledgement requires a bound session");
|
|
756
|
+
}
|
|
757
|
+
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
665
758
|
const ack = await client.postMessage({
|
|
666
759
|
operationId: deterministicUuid(`slack-ack:${interaction.id}`),
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
760
|
+
...(directMessageShortcut
|
|
761
|
+
? { userId: entry.slackUserId }
|
|
762
|
+
: {
|
|
763
|
+
channelId: entry.slackChannelId,
|
|
764
|
+
...(entry.triggerKind === "slash_command"
|
|
765
|
+
? {}
|
|
766
|
+
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
767
|
+
}),
|
|
768
|
+
text: directMessageShortcut
|
|
769
|
+
? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
|
|
770
|
+
: `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`,
|
|
672
771
|
});
|
|
673
|
-
if (entry.triggerKind === "slash_command") {
|
|
674
|
-
await rekeySlackInteractionRoute(deps.db, {
|
|
772
|
+
if (entry.triggerKind === "slash_command" || directMessageShortcut) {
|
|
773
|
+
const rekeyed = await rekeySlackInteractionRoute(deps.db, {
|
|
675
774
|
...interaction,
|
|
676
|
-
routeKey: slackRouteKey(
|
|
775
|
+
routeKey: slackRouteKey(ack.channelId, ack.timestamp),
|
|
776
|
+
slackChannelId: ack.channelId,
|
|
677
777
|
slackThreadTs: ack.timestamp,
|
|
678
778
|
ackSlackMessageTs: ack.timestamp,
|
|
779
|
+
repairUnacknowledgedPrivateShortcutDelivery: directMessageShortcut,
|
|
679
780
|
});
|
|
781
|
+
if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
|
|
680
782
|
}
|
|
681
783
|
}
|
|
682
784
|
|
|
@@ -1407,6 +1509,12 @@ function slackRouteKey(channelId: string, threadTs: string) {
|
|
|
1407
1509
|
return `${channelId}:${threadTs}`;
|
|
1408
1510
|
}
|
|
1409
1511
|
|
|
1512
|
+
function isDirectMessageShortcut(
|
|
1513
|
+
entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
|
|
1514
|
+
) {
|
|
1515
|
+
return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1410
1518
|
function deterministicUuid(value: string) {
|
|
1411
1519
|
const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
|
|
1412
1520
|
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
package/src/mcp/documents.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { createKnowledgeMemory, listKnowledgeMemories, type Database } from "@opengeni/db";
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import * as z from "zod/v4";
|
|
12
|
+
import { mcpMutationReceipt } from "./receipts";
|
|
12
13
|
|
|
13
14
|
const SearchInputSchema = {
|
|
14
15
|
query: z.string().min(1),
|
|
@@ -192,31 +193,47 @@ export function buildDocumentsMcpServer(
|
|
|
192
193
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
193
194
|
},
|
|
194
195
|
},
|
|
195
|
-
async ({ text, kind, scope, sourceRefs, confidence, metadata }) =>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
196
|
+
async ({ text, kind, scope, sourceRefs, confidence, metadata }) => {
|
|
197
|
+
const memory = await createKnowledgeMemory(db, {
|
|
198
|
+
accountId,
|
|
199
|
+
workspaceId,
|
|
200
|
+
status: "proposed",
|
|
201
|
+
kind: kind ?? "semantic",
|
|
202
|
+
scope: scope ?? "workspace",
|
|
203
|
+
text,
|
|
204
|
+
sourceRefs:
|
|
205
|
+
sourceRefs?.map((sourceRef) => ({
|
|
206
|
+
...sourceRef,
|
|
207
|
+
metadata: sourceRef.metadata ?? {},
|
|
208
|
+
})) ?? [],
|
|
209
|
+
confidence: confidence ?? 0.5,
|
|
210
|
+
metadata: metadata ?? {},
|
|
211
|
+
createdBySessionId: options.createdBySessionId,
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{
|
|
216
|
+
type: "text",
|
|
217
|
+
text: JSON.stringify(
|
|
218
|
+
mcpMutationReceipt({
|
|
219
|
+
operation: "memory_propose",
|
|
220
|
+
committed: true,
|
|
221
|
+
outcome: "created",
|
|
222
|
+
changed: true,
|
|
223
|
+
resource: {
|
|
224
|
+
type: "knowledge_memory",
|
|
225
|
+
id: memory.id,
|
|
226
|
+
state: memory.status,
|
|
227
|
+
},
|
|
228
|
+
timestamp: memory.updatedAt,
|
|
229
|
+
idempotency: { status: "not_supported" },
|
|
230
|
+
nextAction: { tool: "memory_search", arguments: {} },
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
},
|
|
220
237
|
);
|
|
221
238
|
|
|
222
239
|
return server;
|