@opengeni/api-router 0.2.2 → 0.4.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-I2EDWHVF.js} +82 -26
- package/dist/chunk-I2EDWHVF.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/mcp/server.ts +14 -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-I2EDWHVF.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.4.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.4",
|
|
50
|
+
"@opengeni/contracts": "^0.6.0",
|
|
51
|
+
"@opengeni/core": "^0.4.0",
|
|
52
|
+
"@opengeni/db": "^0.4.0",
|
|
53
|
+
"@opengeni/documents": "^0.2.4",
|
|
54
|
+
"@opengeni/events": "^0.2.4",
|
|
55
|
+
"@opengeni/github": "^0.2.4",
|
|
56
56
|
"@opengeni/observability": "^0.2.1",
|
|
57
|
-
"@opengeni/runtime": "^0.
|
|
58
|
-
"@opengeni/storage": "^0.2.
|
|
57
|
+
"@opengeni/runtime": "^0.3.0",
|
|
58
|
+
"@opengeni/storage": "^0.2.4",
|
|
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,
|
|
@@ -628,9 +629,17 @@ function registerWorkspaceOrchestrationTools(
|
|
|
628
629
|
description: "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and workspace environment attachment. Environment attachment happens at creation only — it cannot be added to a running session — and requires the environments:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
|
|
629
630
|
inputSchema: {
|
|
630
631
|
initialMessage: z4.string().min(1),
|
|
632
|
+
// Per-session agent persona/system instructions for the spawned worker
|
|
633
|
+
// (a per-agent-type prompt). Delivered system-level, composed AFTER the
|
|
634
|
+
// workspace persona; never shown in the worker's timeline. Trimmed,
|
|
635
|
+
// non-empty, max 32768 chars (re-validated by the contracts schema).
|
|
636
|
+
instructions: z4.string().min(1).max(32768).optional(),
|
|
631
637
|
goal: z4.unknown().optional(),
|
|
632
638
|
resources: z4.array(z4.unknown()).optional(),
|
|
633
639
|
tools: z4.array(z4.unknown()).optional(),
|
|
640
|
+
// Per-session third-party MCP servers. Credential header values are
|
|
641
|
+
// accepted only at create and never appear in responses/events.
|
|
642
|
+
mcpServers: z4.array(z4.unknown()).optional(),
|
|
634
643
|
environmentId: z4.string().uuid().optional(),
|
|
635
644
|
model: z4.string().min(1).optional(),
|
|
636
645
|
reasoningEffort: z4.string().optional(),
|
|
@@ -691,11 +700,15 @@ function registerWorkspaceOrchestrationTools(
|
|
|
691
700
|
inputSchema: {
|
|
692
701
|
sessionId: z4.string().uuid(),
|
|
693
702
|
text: z4.string().min(1),
|
|
703
|
+
// Header-value rotation only. URL/name/tool settings are immutable
|
|
704
|
+
// after create; core enforces mcp_servers:attach on this field.
|
|
705
|
+
mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
|
|
694
706
|
},
|
|
695
|
-
}, async ({ sessionId, text }) => {
|
|
707
|
+
}, async ({ sessionId, text, mcpCredentialUpdates }) => {
|
|
696
708
|
const { accepted, turn } = await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
|
|
697
709
|
text,
|
|
698
710
|
toolsProvided: false,
|
|
711
|
+
mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) => SessionMcpCredentialUpdateInput.parse(update)),
|
|
699
712
|
});
|
|
700
713
|
return json({ event: accepted, turnId: turn.id });
|
|
701
714
|
});
|
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. */
|