@opengeni/api-router 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-XSYUDIX3.js";
3
+ } from "./chunk-SVBM6RM6.js";
4
4
 
5
5
  // src/index.ts
6
6
  import { dbSearchPath, getSettings, resolveNatsCalloutConfig, resolveNatsControlPlaneAuth, retryStartupDependency, startupRetryOptions } from "@opengeni/config";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,18 +44,18 @@
44
44
  "@aws-sdk/s3-request-presigner": "^3.1044.0",
45
45
  "@hono/zod-validator": "^0.7.6",
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
- "@opengeni/agent-proto": "^0.2.0",
48
- "@opengeni/codex": "^0.2.0",
49
- "@opengeni/config": "^0.2.1",
50
- "@opengeni/contracts": "^0.4.0",
51
- "@opengeni/core": "^0.2.1",
52
- "@opengeni/db": "^0.2.1",
53
- "@opengeni/documents": "^0.2.1",
54
- "@opengeni/events": "^0.2.1",
55
- "@opengeni/github": "^0.2.1",
56
- "@opengeni/observability": "^0.2.0",
57
- "@opengeni/runtime": "^0.2.1",
58
- "@opengeni/storage": "^0.2.1",
47
+ "@opengeni/agent-proto": "^0.2.1",
48
+ "@opengeni/codex": "^0.2.1",
49
+ "@opengeni/config": "^0.2.3",
50
+ "@opengeni/contracts": "^0.5.0",
51
+ "@opengeni/core": "^0.3.0",
52
+ "@opengeni/db": "^0.3.0",
53
+ "@opengeni/documents": "^0.2.3",
54
+ "@opengeni/events": "^0.2.3",
55
+ "@opengeni/github": "^0.2.3",
56
+ "@opengeni/observability": "^0.2.1",
57
+ "@opengeni/runtime": "^0.2.3",
58
+ "@opengeni/storage": "^0.2.3",
59
59
  "@temporalio/client": "^1.17.0",
60
60
  "better-auth": "^1.6.14",
61
61
  "hono": "^4.12.18",
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
+ SessionMcpCredentialUpdateInput,
3
4
  WorkspaceEnvironmentVariableName,
4
5
  type AccessGrant,
5
6
  type GitHubRepository,
@@ -631,6 +632,9 @@ function registerWorkspaceOrchestrationTools(
631
632
  goal: z4.unknown().optional(),
632
633
  resources: z4.array(z4.unknown()).optional(),
633
634
  tools: z4.array(z4.unknown()).optional(),
635
+ // Per-session third-party MCP servers. Credential header values are
636
+ // accepted only at create and never appear in responses/events.
637
+ mcpServers: z4.array(z4.unknown()).optional(),
634
638
  environmentId: z4.string().uuid().optional(),
635
639
  model: z4.string().min(1).optional(),
636
640
  reasoningEffort: z4.string().optional(),
@@ -691,11 +695,15 @@ function registerWorkspaceOrchestrationTools(
691
695
  inputSchema: {
692
696
  sessionId: z4.string().uuid(),
693
697
  text: z4.string().min(1),
698
+ // Header-value rotation only. URL/name/tool settings are immutable
699
+ // after create; core enforces mcp_servers:attach on this field.
700
+ mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
694
701
  },
695
- }, async ({ sessionId, text }) => {
702
+ }, async ({ sessionId, text, mcpCredentialUpdates }) => {
696
703
  const { accepted, turn } = await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
697
704
  text,
698
705
  toolsProvided: false,
706
+ mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) => SessionMcpCredentialUpdateInput.parse(update)),
699
707
  });
700
708
  return json({ event: accepted, turnId: turn.id });
701
709
  });
@@ -56,15 +56,15 @@ import {
56
56
  updateQueuedSessionTurn,
57
57
  type AppendEventInput,
58
58
  } from "@opengeni/db";
59
- import { appendAndPublishEvents } from "@opengeni/events";
59
+ import { appendAndPublishEvents, coalesceSessionEventDeltas } from "@opengeni/events";
60
60
  import { withChannelA } from "../sandbox/channel-a";
61
61
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
62
62
  import type { Context, Hono } from "hono";
63
63
  import { HTTPException } from "hono/http-exception";
64
64
  import { requireAccessGrant } from "@opengeni/core";
65
65
  import type { ApiRouteDeps } from "@opengeni/core";
66
- import { attachViewer, detachViewer, heartbeatViewer, mintDesktopStream, mintTerminalStream, readGroupLease, viewerHeartbeatIntervalMs, type DesktopStreamMint, type TerminalStreamMint } from "../sandbox/viewer";
67
- import { settingsWithEnabledCapabilityMcpServers } from "@opengeni/core";
66
+ import { attachViewer, detachViewer, heartbeatViewer, mintDesktopStream, mintTerminalStream, readGroupLease, resolveActiveDesktopTransport, viewerHeartbeatIntervalMs, type DesktopStreamMint, type TerminalStreamMint } from "../sandbox/viewer";
67
+ import { settingsWithEnabledCapabilityMcpServers, settingsWithSessionMcpServerMetadata } from "@opengeni/core";
68
68
  import {
69
69
  normalizeResources,
70
70
  validateFileResources,
@@ -284,9 +284,16 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
284
284
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
285
285
  const sessionId = c.req.param("sessionId");
286
286
  await assertSessionExists(db, workspaceId, sessionId);
287
- const after = Number(c.req.query("after") ?? 0);
288
- const limit = Number(c.req.query("limit") ?? 500);
289
- return c.json(await listSessionEvents(db, workspaceId, sessionId, Number.isFinite(after) ? after : 0, Number.isFinite(limit) ? limit : 500));
287
+ const after = eventSequence(c.req.query("after"), 0);
288
+ const before = optionalEventSequence(c.req.query("before"));
289
+ const compact = compactEvents(c.req.query("compact"));
290
+ const limit = eventListLimit(c.req.query("limit"), compact ? 5000 : 2000);
291
+ const events = await listSessionEvents(db, workspaceId, sessionId, {
292
+ after,
293
+ ...(before !== undefined ? { before } : {}),
294
+ limit,
295
+ });
296
+ return c.json(compact ? coalesceSessionEventDeltas(events) : events);
290
297
  });
291
298
 
292
299
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
@@ -317,14 +324,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
317
324
  // A turn-update may switch the queued turn's model; reject one the host
318
325
  // does not expose (omitted leaves the existing model unchanged).
319
326
  assertConfiguredModel(settings, payload.model);
320
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
327
+ const session = await requireSession(db, workspaceId, sessionId);
328
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(
329
+ await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings),
330
+ session.mcpServers,
331
+ );
321
332
  const resources = payload.resources !== undefined ? normalizeResources(payload.resources) : existing.resources;
322
333
  const tools = payload.tools !== undefined ? validateToolRefs(payload.tools, runtimeSettings) : existing.tools;
323
334
  if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
324
335
  throw new HTTPException(503, { message: "object storage is not configured" });
325
336
  }
326
337
  await validateFileResources(db, workspaceId, resources);
327
- const session = await requireSession(db, workspaceId, sessionId);
328
338
  await validateGitHubRepositorySelection(db, workspaceId, [...session.resources, ...resources]);
329
339
  const turn = await updateQueuedSessionTurn(db, workspaceId, turnId, {
330
340
  ...(payload.prompt !== undefined ? { prompt: payload.prompt.trim() } : {}),
@@ -388,6 +398,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
388
398
  toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
389
399
  model: event.payload.model ?? null,
390
400
  reasoningEffort: event.payload.reasoningEffort ?? null,
401
+ mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
391
402
  ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
392
403
  });
393
404
  return c.json(accepted, 202);
@@ -579,28 +590,33 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
579
590
  : {}),
580
591
  });
581
592
 
582
- // SWAP-CASE desktop transport: the negotiation keyed on the HOME backend, but
583
- // the desktop/terminal plane actually runs on the ACTIVE sandbox when one is
584
- // pinned. If that active sandbox is a SELFHOSTED machine, its desktop is the
585
- // RELAY framebuffer (PNG-per-frame) the "relay-frames"/"frames" client, NOT
586
- // the home box's noVNC. Override the cell so the viewer selects the frame
587
- // renderer (the mint already served the machine's relay url). Machine-PRIMARY
588
- // sessions (home backend already selfhosted) negotiate relay-frames directly,
589
- // so the `=== "vnc-ws"` guard skips the extra lookup for them.
593
+ // SWAP-CASE desktop transport (BOTH directions): negotiateCapabilities keyed on
594
+ // the HOME backend, but the pixel plane actually runs on the ACTIVE sandbox and
595
+ // the two backends use DIFFERENT wire transports. The advertised transport MUST
596
+ // match where mintDesktopStream routed the pixels (relay IFF the active sandbox is
597
+ // a selfhosted machine), or the client picks the wrong renderer and the socket
598
+ // closes before it opens:
599
+ // modal-HOME swapped ONTO a selfhosted machine: negotiate says vnc-ws, but the
600
+ // machine's desktop is the RELAY framebuffer (PNG-per-frame) flip to
601
+ // relay-frames/frames. (#171)
602
+ // • selfhosted-HOME swapped AWAY to the cloud group box (activeSandboxId=null OR a
603
+ // non-selfhosted active sandbox): negotiate says relay-frames (home=selfhosted),
604
+ // but there is NO relay producer on the Modal box → the client hangs on a dead
605
+ // relay socket ("desktop stream closed before it opened"). Flip to the Modal
606
+ // noVNC/RFB tunnel (vnc-ws/novnc). This is the mirror of #171 and the missing
607
+ // half that this fixes.
608
+ // The single invariant: advertise relay-frames IFF (activeSandboxId set AND the
609
+ // active sandbox kind is "selfhosted") — EXACTLY mintDesktopStream's routing. When
610
+ // the desktop is available we set the transport from the ACTIVE sandbox in one
611
+ // place (resolveActiveDesktopTransport), covering BOTH swap directions.
590
612
  let responseCapabilities = capabilities;
591
- if (session.activeSandboxId && capabilities.DesktopStream.transport === "vnc-ws") {
592
- const activeSandbox = await getSandbox(db, workspaceId, session.activeSandboxId);
593
- if (activeSandbox?.kind === "selfhosted") {
594
- responseCapabilities = {
595
- ...capabilities,
596
- DesktopStream: {
597
- ...capabilities.DesktopStream,
598
- transport: "relay-frames",
599
- client: "frames",
600
- mode: "read-only",
601
- },
602
- };
603
- }
613
+ if (capabilities.DesktopStream.transport !== null) {
614
+ const activeSandbox = session.activeSandboxId ? await getSandbox(db, workspaceId, session.activeSandboxId) : null;
615
+ const wire = resolveActiveDesktopTransport(activeSandbox?.kind === "selfhosted", settings.sandboxDesktopInteractive !== false);
616
+ responseCapabilities = {
617
+ ...capabilities,
618
+ DesktopStream: { ...capabilities.DesktopStream, ...wire },
619
+ };
604
620
  }
605
621
  return c.json(responseCapabilities);
606
622
  });
@@ -774,8 +790,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
774
790
  streamToken: stream?.token ?? null,
775
791
  streamExpiresAt: stream?.expiresAt ?? null,
776
792
  resolution: stream?.resolution ?? null,
777
- transport: stream ? ("vnc-ws" as const) : null,
778
- client: stream ? ("novnc" as const) : null,
793
+ // Transport MUST match where the pixels were minted: a selfhosted-active box
794
+ // serves the RELAY framebuffer (relay-frames/frames), a Modal box serves noVNC
795
+ // (vnc-ws/novnc). Hardcoding vnc-ws here handed a machine's relay URL to the
796
+ // noVNC renderer (and vice-versa on the swap-away case) → "closed before it
797
+ // opened". Key off the SAME selfhostedActive the mint routed on.
798
+ transport: stream ? (selfhostedActive ? ("relay-frames" as const) : ("vnc-ws" as const)) : null,
799
+ client: stream ? (selfhostedActive ? ("frames" as const) : ("novnc" as const)) : null,
779
800
  // The REAL PTY terminal address (pty-ws), null when degraded.
780
801
  terminalUrl: terminal?.url ?? null,
781
802
  terminalToken: terminal?.token ?? null,
@@ -1070,6 +1091,37 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1070
1091
  });
1071
1092
  }
1072
1093
 
1094
+ function eventListLimit(raw: string | undefined, max = 2000): number {
1095
+ const limit = Number(raw ?? 500);
1096
+ if (!Number.isFinite(limit)) {
1097
+ return 500;
1098
+ }
1099
+ return Math.min(max, Math.max(1, Math.floor(limit)));
1100
+ }
1101
+
1102
+ function compactEvents(raw: string | undefined): boolean {
1103
+ return raw === "1" || raw === "true";
1104
+ }
1105
+
1106
+ function eventSequence(raw: string | undefined, fallback: number): number {
1107
+ const sequence = Number(raw ?? fallback);
1108
+ if (!Number.isFinite(sequence)) {
1109
+ return fallback;
1110
+ }
1111
+ return Math.floor(sequence);
1112
+ }
1113
+
1114
+ function optionalEventSequence(raw: string | undefined): number | undefined {
1115
+ if (raw === undefined) {
1116
+ return undefined;
1117
+ }
1118
+ const sequence = Number(raw);
1119
+ if (!Number.isFinite(sequence)) {
1120
+ return undefined;
1121
+ }
1122
+ return Math.floor(sequence);
1123
+ }
1124
+
1073
1125
  function hasOwnProperty(value: unknown, key: string): boolean {
1074
1126
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1075
1127
  }
@@ -381,6 +381,29 @@ async function dropEstablishedHandle(established: EstablishedSandboxSession | un
381
381
  // is the out-of-band signal to the OTHER viewers of the same session.
382
382
  // ============================================================================
383
383
 
384
+ /**
385
+ * The desktop WIRE transport for the session's ACTIVE sandbox — the single
386
+ * invariant that keeps the advertised transport in lockstep with where
387
+ * mintDesktopStream routed the pixels. It MUST be derived from the ACTIVE sandbox
388
+ * (not the session's HOME backend that negotiateCapabilities keys on): a selfhosted
389
+ * machine serves the RELAY framebuffer (PNG-per-frame → "relay-frames"/"frames",
390
+ * view-only in v1), and a Modal group box serves noVNC/RFB over the 6080 tunnel
391
+ * (→ "vnc-ws"/"novnc", take-control unless the deployment disabled it). Advertising
392
+ * relay-frames for a Modal box (the swap-away case) hands the client a dead relay
393
+ * socket → "desktop stream closed before it opened"; the reverse hands a machine's
394
+ * relay URL to the noVNC renderer. `selfhostedActive` == (activeSandboxId set AND the
395
+ * active sandbox kind is "selfhosted") — EXACTLY mintDesktopStream's routing predicate.
396
+ */
397
+ export function resolveActiveDesktopTransport(
398
+ selfhostedActive: boolean,
399
+ interactive: boolean,
400
+ ): { transport: "relay-frames" | "vnc-ws"; client: "frames" | "novnc"; mode: "read-only" | "interactive" } {
401
+ if (selfhostedActive) {
402
+ return { transport: "relay-frames", client: "frames", mode: "read-only" };
403
+ }
404
+ return { transport: "vnc-ws", client: "novnc", mode: interactive ? "interactive" : "read-only" };
405
+ }
406
+
384
407
  /** The minted pixel cell the handshake/attach folds into the DesktopStream
385
408
  * capability. Null when degraded (no secret, headless backend, display-stack
386
409
  * failure, provider tunnel failure) — degradation is a value, never a throw. */