@opengeni/api-router 0.5.7 → 0.7.3
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 +21 -3
- package/dist/app.js +3 -1
- package/dist/{chunk-HBEJMWD3.js → chunk-EYYTFA7N.js} +2396 -676
- package/dist/chunk-EYYTFA7N.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/app.ts +47 -4
- package/src/github-access.ts +46 -0
- package/src/github-browser-flow.ts +83 -0
- package/src/http/auth.ts +12 -4
- package/src/http/sse.ts +526 -92
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +774 -146
- package/src/mcp/session-view.ts +622 -203
- package/src/mcp/toolspace.ts +110 -25
- package/src/routes/codex.ts +17 -14
- package/src/routes/enrollments.ts +2 -2
- package/src/routes/github.ts +63 -202
- package/src/routes/install.ts +1 -1
- package/src/routes/machines.ts +2 -2
- package/src/routes/sessions.ts +639 -76
- package/src/routes/workspace-capture.ts +56 -38
- package/src/routes/workspaces.ts +30 -11
- package/src/sandbox/access.ts +1 -1
- package/src/sandbox/auth-callout.ts +1 -1
- package/src/sandbox/channel-a.ts +14 -1
- package/src/sandbox/enrollment.ts +4 -4
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +1 -1
- package/src/sandbox/viewer.ts +1 -1
- package/dist/chunk-HBEJMWD3.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Workbench v2 — capture READ serving
|
|
1
|
+
// Workbench v2 — capture READ serving.
|
|
2
2
|
//
|
|
3
3
|
// The two GET capture routes in `sessions.ts` are thin: grant-first (files:read),
|
|
4
4
|
// load the row (DB, RLS-scoped), then delegate the response SHAPING to the two
|
|
@@ -46,13 +46,24 @@ function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; exp
|
|
|
46
46
|
// manifest key, the blob is gone (GC'd), or the bytes fail to parse/validate — a
|
|
47
47
|
// malformed capture is treated as "no capture available" (the list route degrades
|
|
48
48
|
// to {available:false}, the file route to 404). Capture reads must NEVER be worse
|
|
49
|
-
// than the status-quo live/wake fallback
|
|
49
|
+
// than the status-quo live/wake fallback, so a poison row can
|
|
50
50
|
// never 500 the workbench; it degrades and logs.
|
|
51
51
|
async function loadManifest(
|
|
52
52
|
row: WorkspaceCaptureRow,
|
|
53
53
|
storage: CaptureStoragePort,
|
|
54
|
-
): Promise<{
|
|
54
|
+
): Promise<{
|
|
55
|
+
manifest: WorkspaceCaptureManifest;
|
|
56
|
+
byteLength: number;
|
|
57
|
+
stats: WorkspaceCaptureStats;
|
|
58
|
+
} | null> {
|
|
55
59
|
if (!row.manifestKey) return null;
|
|
60
|
+
const stats = WorkspaceCaptureStats.safeParse(row.stats);
|
|
61
|
+
if (!stats.success) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`workspace capture read — row stats failed schema validation (session=${row.sessionId} rev=${row.revision})`,
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
56
67
|
const blob = await storage.getObjectBytes(row.manifestKey);
|
|
57
68
|
if (!blob) return null;
|
|
58
69
|
let json: unknown;
|
|
@@ -71,7 +82,39 @@ async function loadManifest(
|
|
|
71
82
|
);
|
|
72
83
|
return null;
|
|
73
84
|
}
|
|
74
|
-
|
|
85
|
+
const manifest = parsed.data;
|
|
86
|
+
const servedStats = stats.data;
|
|
87
|
+
const statsMatch =
|
|
88
|
+
manifest.stats.repoCount === servedStats.repoCount &&
|
|
89
|
+
manifest.stats.fileCount === servedStats.fileCount &&
|
|
90
|
+
manifest.stats.additions === servedStats.additions &&
|
|
91
|
+
manifest.stats.deletions === servedStats.deletions &&
|
|
92
|
+
manifest.stats.totalBytes === servedStats.totalBytes &&
|
|
93
|
+
manifest.stats.tooLargeCount === servedStats.tooLargeCount &&
|
|
94
|
+
manifest.stats.binaryCount === servedStats.binaryCount &&
|
|
95
|
+
manifest.stats.treeEntryCount === servedStats.treeEntryCount &&
|
|
96
|
+
manifest.stats.treeTruncated === servedStats.treeTruncated &&
|
|
97
|
+
manifest.stats.durationMs === servedStats.durationMs &&
|
|
98
|
+
(manifest.stats.fingerprint ?? null) === (servedStats.fingerprint ?? null);
|
|
99
|
+
if (
|
|
100
|
+
manifest.revision !== row.revision ||
|
|
101
|
+
manifest.capturedAt !== row.capturedAt ||
|
|
102
|
+
manifest.turnId !== row.turnId ||
|
|
103
|
+
manifest.leaseEpoch !== row.leaseEpoch ||
|
|
104
|
+
!statsMatch ||
|
|
105
|
+
manifest.repos.length !== manifest.stats.repoCount ||
|
|
106
|
+
manifest.files.length !== manifest.stats.fileCount ||
|
|
107
|
+
manifest.treeTruncated !== manifest.stats.treeTruncated
|
|
108
|
+
) {
|
|
109
|
+
// A valid blob under the wrong row/key is still poison: row metadata drives
|
|
110
|
+
// cache identity and revision pinning in the client. Never combine two
|
|
111
|
+
// different captures into one apparently authoritative response.
|
|
112
|
+
console.warn(
|
|
113
|
+
`workspace capture read — manifest identity did not match row (session=${row.sessionId} rev=${row.revision})`,
|
|
114
|
+
);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return { manifest, byteLength: blob.bytes.byteLength, stats: servedStats };
|
|
75
118
|
}
|
|
76
119
|
|
|
77
120
|
/**
|
|
@@ -105,15 +148,12 @@ export async function serveWorkspaceCapture(
|
|
|
105
148
|
});
|
|
106
149
|
}
|
|
107
150
|
if (row.state !== "available" || !row.manifestKey) return { available: false };
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
return { available: false };
|
|
116
|
-
}
|
|
151
|
+
// Validate every manifest before serving it, including the rare >2MB signed
|
|
152
|
+
// path. Previously that branch signed arbitrary bytes merely because they
|
|
153
|
+
// exceeded the inline cap, allowing a poison/mis-keyed blob to bypass both the
|
|
154
|
+
// schema and row-identity checks.
|
|
155
|
+
const loaded = await loadManifest(row, storage);
|
|
156
|
+
if (!loaded) return { available: false };
|
|
117
157
|
const meta = {
|
|
118
158
|
available: true as const,
|
|
119
159
|
revision: row.revision,
|
|
@@ -121,34 +161,12 @@ export async function serveWorkspaceCapture(
|
|
|
121
161
|
turnId: row.turnId,
|
|
122
162
|
leaseEpoch: row.leaseEpoch,
|
|
123
163
|
sizeBytes: row.sizeBytes ?? 0,
|
|
124
|
-
stats: stats
|
|
164
|
+
stats: loaded.stats,
|
|
125
165
|
};
|
|
126
|
-
|
|
127
|
-
if (!blob) {
|
|
128
|
-
// Manifest raced GC between the row read and the blob fetch — degrade to the
|
|
129
|
-
// cold-fallback state rather than 500.
|
|
130
|
-
return { available: false };
|
|
131
|
-
}
|
|
132
|
-
if (blob.bytes.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
|
|
133
|
-
let json: unknown;
|
|
134
|
-
try {
|
|
135
|
-
json = JSON.parse(new TextDecoder().decode(blob.bytes));
|
|
136
|
-
} catch {
|
|
137
|
-
console.warn(
|
|
138
|
-
`workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
|
|
139
|
-
);
|
|
140
|
-
return { available: false };
|
|
141
|
-
}
|
|
142
|
-
const manifest = WorkspaceCaptureManifest.safeParse(json);
|
|
143
|
-
if (!manifest.success) {
|
|
144
|
-
console.warn(
|
|
145
|
-
`workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
|
|
146
|
-
);
|
|
147
|
-
return { available: false };
|
|
148
|
-
}
|
|
166
|
+
if (loaded.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
|
|
149
167
|
return GetWorkspaceCaptureResponse.parse({
|
|
150
168
|
...meta,
|
|
151
|
-
manifest: manifest
|
|
169
|
+
manifest: loaded.manifest,
|
|
152
170
|
manifestUrl: null,
|
|
153
171
|
});
|
|
154
172
|
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
UpdateWorkspaceModelPolicyRequest,
|
|
8
8
|
UpdateWorkspaceRequest,
|
|
9
9
|
UpdateWorkspaceSettingsRequest,
|
|
10
|
+
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
10
11
|
WorkspaceInferenceControlRequest,
|
|
11
12
|
Workspace,
|
|
12
13
|
WorkspaceMember,
|
|
14
|
+
workspaceControlUtf8Bytes,
|
|
13
15
|
type AccessContext,
|
|
14
16
|
type Permission,
|
|
15
17
|
} from "@opengeni/contracts";
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
updateWorkspaceSettings,
|
|
35
37
|
upsertWorkspaceModelPolicy,
|
|
36
38
|
} from "@opengeni/db";
|
|
39
|
+
import { boundWorkspaceControlHttpPage } from "@opengeni/events";
|
|
37
40
|
import type { Hono } from "hono";
|
|
38
41
|
import { HTTPException } from "hono/http-exception";
|
|
39
42
|
import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
|
|
@@ -171,12 +174,18 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
171
174
|
app.post("/v1/workspaces/:workspaceId/inference-control", async (c) => {
|
|
172
175
|
const workspaceId = c.req.param("workspaceId");
|
|
173
176
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
174
|
-
|
|
177
|
+
if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
|
|
178
|
+
throw new HTTPException(400, { message: "workspace-control actor is too large" });
|
|
179
|
+
}
|
|
180
|
+
const parsed = WorkspaceInferenceControlRequest.safeParse(await c.req.json().catch(() => null));
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
throw new HTTPException(400, { message: "invalid workspace inference-control request" });
|
|
183
|
+
}
|
|
175
184
|
return c.json(
|
|
176
185
|
await controlHumanWorkspace(
|
|
177
186
|
{ db: deps.db, bus: deps.bus, workflowClient: deps.workflowClient },
|
|
178
187
|
{ accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
|
|
179
|
-
|
|
188
|
+
parsed.data,
|
|
180
189
|
),
|
|
181
190
|
);
|
|
182
191
|
});
|
|
@@ -185,21 +194,31 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
185
194
|
const workspaceId = c.req.param("workspaceId");
|
|
186
195
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
187
196
|
const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
197
|
+
const limit = boundedLimit(c.req.query("limit"));
|
|
198
|
+
const fetched = await listWorkspaceControlEvents(deps.db, workspaceId, after, limit + 1);
|
|
199
|
+
const countHasMore = fetched.length > limit;
|
|
200
|
+
const page = boundWorkspaceControlHttpPage(fetched.slice(0, limit));
|
|
201
|
+
const truncated = countHasMore || page.truncated;
|
|
202
|
+
c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
|
|
203
|
+
c.header("X-OpenGeni-Page-Truncated", String(truncated));
|
|
204
|
+
if (page.nextSequence !== null) {
|
|
205
|
+
c.header("X-OpenGeni-Next-After", String(page.nextSequence));
|
|
206
|
+
}
|
|
207
|
+
return c.json(page.events);
|
|
196
208
|
});
|
|
197
209
|
|
|
198
210
|
app.get("/v1/workspaces/:workspaceId/control-events/stream", async (c) => {
|
|
199
211
|
const workspaceId = c.req.param("workspaceId");
|
|
200
212
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
201
213
|
const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
|
|
202
|
-
return await sseWorkspaceControlStream(
|
|
214
|
+
return await sseWorkspaceControlStream(
|
|
215
|
+
deps.db,
|
|
216
|
+
deps.bus,
|
|
217
|
+
workspaceId,
|
|
218
|
+
after,
|
|
219
|
+
c.req.raw.signal,
|
|
220
|
+
{ observability: deps.observability },
|
|
221
|
+
);
|
|
203
222
|
});
|
|
204
223
|
|
|
205
224
|
app.put("/v1/workspaces/:workspaceId/default-rig", async (c) => {
|
package/src/sandbox/access.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// apps/api/src/sandbox/access.ts — the API-tier sandbox access seam.
|
|
2
2
|
//
|
|
3
3
|
// This is the foundation of the API-DIRECT control plane
|
|
4
|
-
// (docs/
|
|
4
|
+
// (docs/connected-machines.md): the apps/api process constructs its OWN
|
|
5
5
|
// sandbox client and resumes boxes by id IN-PROCESS, so non-turn ops (viewer
|
|
6
6
|
// attach, FS/git reads, tunnel URL mint) never touch Temporal or a worker.
|
|
7
7
|
//
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// apps/api/src/sandbox/auth-callout.ts — the NATS AUTH-CALLOUT responder (the
|
|
2
|
-
// bring-your-own-compute M-AUTH tenancy boundary;
|
|
2
|
+
// bring-your-own-compute M-AUTH tenancy boundary; NATS Accounts per
|
|
3
3
|
// workspace + §17 the isolation smoke + §19 the NATS-Accounts-misconfig leak risk).
|
|
4
4
|
//
|
|
5
5
|
// THE BOUNDARY THIS CLOSES: an external agent connects to NATS presenting its
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -50,7 +50,11 @@ import {
|
|
|
50
50
|
ChannelAConflictError,
|
|
51
51
|
ChannelANotFoundError,
|
|
52
52
|
ChannelAUnsupportedError,
|
|
53
|
+
ChannelAUnavailableError,
|
|
53
54
|
ChannelAValidationError,
|
|
55
|
+
toolspaceTokenFileFromEnvironment,
|
|
56
|
+
withToolspaceTokenSession,
|
|
57
|
+
withRunCredentialsSession,
|
|
54
58
|
type ChannelASession,
|
|
55
59
|
type EstablishedSandboxSession,
|
|
56
60
|
} from "@opengeni/runtime/sandbox";
|
|
@@ -293,9 +297,16 @@ export async function withChannelA<T>(
|
|
|
293
297
|
established,
|
|
294
298
|
).session
|
|
295
299
|
: established.session;
|
|
300
|
+
const credentialSession = withRunCredentialsSession(routedSession as object, session.id);
|
|
301
|
+
const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
|
|
302
|
+
? withToolspaceTokenSession(
|
|
303
|
+
credentialSession,
|
|
304
|
+
toolspaceTokenFileFromEnvironment(environment, session.id),
|
|
305
|
+
)
|
|
306
|
+
: credentialSession;
|
|
296
307
|
|
|
297
308
|
const service = new SandboxChannelAService({
|
|
298
|
-
session:
|
|
309
|
+
session: scopedSession as ChannelASession,
|
|
299
310
|
leaseEpoch: leaseSnapshot.leaseEpoch,
|
|
300
311
|
emit,
|
|
301
312
|
});
|
|
@@ -313,6 +324,8 @@ export async function withChannelA<T>(
|
|
|
313
324
|
* already-HTTPException unchanged. */
|
|
314
325
|
export function mapChannelAError(error: unknown): unknown {
|
|
315
326
|
if (error instanceof HTTPException) return error;
|
|
327
|
+
if (error instanceof ChannelAUnavailableError)
|
|
328
|
+
return new HTTPException(503, { message: error.message });
|
|
316
329
|
if (error instanceof ChannelAValidationError)
|
|
317
330
|
return new HTTPException(400, { message: error.message });
|
|
318
331
|
if (error instanceof ChannelANotFoundError)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// apps/api/src/sandbox/enrollment.ts — the API-DIRECT enrollment device-flow seam
|
|
2
|
-
// (M5 of the bring-your-own-compute mega-PR;
|
|
2
|
+
// (M5 of the bring-your-own-compute mega-PR; enrollment + §18 LOUD
|
|
3
3
|
// consent). This is the service layer the routes (routes/enrollments.ts) call — it
|
|
4
4
|
// mirrors the channel-a.ts / viewer.ts split (a thin route over a focused service).
|
|
5
5
|
//
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// placeholder for the per-workspace NATS Account creds [infra-deferred]);
|
|
21
21
|
// denied/expired/disabled → the typed state.
|
|
22
22
|
//
|
|
23
|
-
// SECURITY
|
|
23
|
+
// SECURITY: device_code/user_code are CSPRNG-unguessable + short-TTL +
|
|
24
24
|
// single-use; approve is strictly workspace-gated (the route asserts the grant); the
|
|
25
25
|
// signing secret value is NEVER logged. Rate-limiting of start/poll is enforced at
|
|
26
26
|
// the route. The consent record (who/when/what) lives on the request row.
|
|
@@ -78,7 +78,7 @@ export const DEVICE_POLL_INTERVAL_SECONDS = 5;
|
|
|
78
78
|
// enrollment status on every (re)connect (auth-callout.ts) — a revoked machine is
|
|
79
79
|
// denied regardless of bearer life — exactly as the long-lived relay token relies on.
|
|
80
80
|
export const ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
|
|
81
|
-
// The relay PRODUCER token (the `ogr_` token; M8b
|
|
81
|
+
// The relay PRODUCER token (the `ogr_` token; M8b) is ENROLLMENT-scoped,
|
|
82
82
|
// NOT per-stream: the agent presents it on every channel registration for the life
|
|
83
83
|
// of its run, and the producer side has no per-viewer epoch fence (that is the
|
|
84
84
|
// VIEWER's `ogs_` token's job). So it is long-lived — 30 days — re-minted on every
|
|
@@ -492,7 +492,7 @@ async function buildEnrollmentCredentials(
|
|
|
492
492
|
// Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
|
|
493
493
|
// The agent's relay producer appends only its routing query and assumes the base
|
|
494
494
|
// already carries the relay's `/stream` route; a path-less base 400s the dial and
|
|
495
|
-
// makes the terminal/desktop streams unreachable
|
|
495
|
+
// makes the terminal/desktop streams unreachable.
|
|
496
496
|
relayUrl: relayDialBaseFromSettings(settings),
|
|
497
497
|
relayToken,
|
|
498
498
|
// M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// apps/api/src/sandbox/machines.ts — the M10 Machines-DASHBOARD service (
|
|
1
|
+
// apps/api/src/sandbox/machines.ts — the M10 Machines-DASHBOARD service (design
|
|
2
2
|
// §10.7). Builds the `MachinesResponse` the dashboard renders: the workspace's
|
|
3
3
|
// enrolled selfhosted machines, each enriched with
|
|
4
4
|
// * STATE — the M3 liveness (online/reconnecting/offline) overlaid with the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// apps/api/src/sandbox/metrics-ingestion.ts — the M10 metrics INGESTION consumer
|
|
2
|
-
//
|
|
2
|
+
// + the connect-Hello DISPLAY-REFRESH consumer. The
|
|
3
3
|
// enrolled agent piggybacks a `MetricsSample` on its ~5s heartbeat (an
|
|
4
4
|
// `AgentEvent` published one-way on `agent.<ws>.<id>.events`) and publishes a
|
|
5
5
|
// `Hello` (its live self-description) on `agent.<ws>.<id>.hello` on every connect
|
package/src/sandbox/viewer.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function sessionAttachEnvironment(
|
|
|
141
141
|
// the same env from runSettings.sandboxBackend = the session's backend. An
|
|
142
142
|
// attach env keyed off the deployment default would cold-create e.g. an e2b
|
|
143
143
|
// session's box with /workspace-rooted values while its turn declares
|
|
144
|
-
// /home/user
|
|
144
|
+
// /home/user ones — the same guard-killed first turn all over again.
|
|
145
145
|
const settingsForSession =
|
|
146
146
|
session.sandboxBackend !== services.settings.sandboxBackend
|
|
147
147
|
? { ...services.settings, sandboxBackend: session.sandboxBackend }
|