@opengeni/api-router 0.2.2 → 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/app.js +1 -1
- package/dist/{chunk-XSYUDIX3.js → chunk-SVBM6RM6.js} +77 -26
- package/dist/chunk-SVBM6RM6.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/mcp/server.ts +9 -1
- package/src/routes/sessions.ts +83 -31
- package/src/sandbox/viewer.ts +23 -0
- package/dist/chunk-XSYUDIX3.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "./chunk-
|
|
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.
|
|
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": {
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
47
|
"@opengeni/agent-proto": "^0.2.1",
|
|
48
48
|
"@opengeni/codex": "^0.2.1",
|
|
49
|
-
"@opengeni/config": "^0.2.
|
|
50
|
-
"@opengeni/contracts": "^0.
|
|
51
|
-
"@opengeni/core": "^0.
|
|
52
|
-
"@opengeni/db": "^0.
|
|
53
|
-
"@opengeni/documents": "^0.2.
|
|
54
|
-
"@opengeni/events": "^0.2.
|
|
55
|
-
"@opengeni/github": "^0.2.
|
|
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
56
|
"@opengeni/observability": "^0.2.1",
|
|
57
|
-
"@opengeni/runtime": "^0.2.
|
|
58
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
});
|
package/src/routes/sessions.ts
CHANGED
|
@@ -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 =
|
|
288
|
-
const
|
|
289
|
-
|
|
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
|
|
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:
|
|
583
|
-
// the
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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 (
|
|
592
|
-
const activeSandbox = await getSandbox(db, workspaceId, session.activeSandboxId);
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
}
|
package/src/sandbox/viewer.ts
CHANGED
|
@@ -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. */
|