@opengeni/api-router 0.2.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.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte/token caps for the cross-session read tools (`session_events`,
|
|
3
|
+
* `session_get`) exposed to manager-style agents over MCP.
|
|
4
|
+
*
|
|
5
|
+
* A long-lived manager session monitors its spawned workers by reading their
|
|
6
|
+
* event timeline. A worker's events carry verbatim model output and, worse,
|
|
7
|
+
* verbatim TOOL OUTPUTS (`agent.toolCall.output.payload.output`) and raw tool
|
|
8
|
+
* call items (`agent.toolCall.created.payload.raw` / `.arguments`). Those are
|
|
9
|
+
* sized for the worker's own context, not the manager's: a single
|
|
10
|
+
* `session_events` page (the DB limit caps event COUNT, not BYTES) can return
|
|
11
|
+
* tens of thousands of characters, and a manager that pages a busy worker piles
|
|
12
|
+
* hundreds of thousands of characters into its own context in one monitoring
|
|
13
|
+
* turn — the exact "parent ingests child" blow-up that bricks the manager.
|
|
14
|
+
*
|
|
15
|
+
* The manager rarely needs a worker's full message deltas / tool outputs
|
|
16
|
+
* verbatim; it needs status + recent progress. So these tools cap what they
|
|
17
|
+
* hand back in two stages, both pure and exhaustively testable here:
|
|
18
|
+
*
|
|
19
|
+
* 1. PER-EVENT FIELD TRIM (`capEventPayload` / `capPayloadValue`): walk each
|
|
20
|
+
* event's payload and clamp any over-long string (and any over-large nested
|
|
21
|
+
* object, by serializing then clamping) to a per-field budget, leaving an
|
|
22
|
+
* explicit `…N chars truncated…` marker. Type-agnostic: it targets whatever
|
|
23
|
+
* field is fat (`text`, `output`, `arguments`, `raw`, `delta`, …) without
|
|
24
|
+
* enumerating event types, so a new fat event type is capped automatically.
|
|
25
|
+
*
|
|
26
|
+
* 2. HEAD+TAIL PAGE BUDGET (`capEventPage`): after per-event trim, if the page
|
|
27
|
+
* still exceeds the total token budget, keep a HEAD (oldest, for entry
|
|
28
|
+
* context) and a TAIL (newest, for recent progress) of events and drop the
|
|
29
|
+
* middle, inserting one synthetic marker event that says how many were
|
|
30
|
+
* dropped and how to get them (page with `after`/`limit`, or read the
|
|
31
|
+
* notebook). Pagination semantics are preserved: `nextAfter` is still the
|
|
32
|
+
* real highest `sequence` returned, so the next page starts exactly where
|
|
33
|
+
* this one ended.
|
|
34
|
+
*
|
|
35
|
+
* Worker-side and UI consumers never go through here — they call the DB
|
|
36
|
+
* functions or the REST routes directly. This module only shapes the MCP tool
|
|
37
|
+
* result a manager model reads, and it is intentionally dependency-free (no DB)
|
|
38
|
+
* so the cap logic can be unit-tested in isolation.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { SessionEvent } from "@opengeni/contracts";
|
|
42
|
+
|
|
43
|
+
// ~4 chars per token is the same coarse estimate the runtime compaction path
|
|
44
|
+
// uses; we only need an order-of-magnitude budget, not exact tokenization.
|
|
45
|
+
const CHARS_PER_TOKEN = 4;
|
|
46
|
+
|
|
47
|
+
export function estimateTokensFromChars(chars: number): number {
|
|
48
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function estimateValueTokens(value: unknown): number {
|
|
52
|
+
return estimateTokensFromChars(safeStringify(value).length);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type EventCapConfig = {
|
|
56
|
+
// Per-event cap: max characters any single string field (or serialized
|
|
57
|
+
// nested object) inside an event payload may contribute before it is clamped
|
|
58
|
+
// with a truncation marker.
|
|
59
|
+
perFieldChars: number;
|
|
60
|
+
// Total page cap: max estimated tokens the whole returned event array may
|
|
61
|
+
// occupy. When the per-event-trimmed page still exceeds this, head+tail
|
|
62
|
+
// selection drops the middle.
|
|
63
|
+
pageTokenBudget: number;
|
|
64
|
+
// When head+tail selection kicks in, how many events to keep at each end.
|
|
65
|
+
headEvents: number;
|
|
66
|
+
tailEvents: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ~2k chars (~500 tokens) per fat field keeps a status glance readable without
|
|
70
|
+
// shipping a worker's whole tool output. ~10k-token page budget sits in the
|
|
71
|
+
// 8–12k target band; head/tail of 8 keeps entry context plus recent progress.
|
|
72
|
+
export const DEFAULT_EVENT_CAP: EventCapConfig = {
|
|
73
|
+
perFieldChars: 2_000,
|
|
74
|
+
pageTokenBudget: 10_000,
|
|
75
|
+
headEvents: 8,
|
|
76
|
+
tailEvents: 8,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ~6k chars (~1.5k tokens) for a single session detail blob: resources/tools/
|
|
80
|
+
// metadata are normally tiny, but agent-set metadata is unbounded, so clamp it.
|
|
81
|
+
export const DEFAULT_SESSION_DETAIL_CHARS = 6_000;
|
|
82
|
+
|
|
83
|
+
function safeStringify(value: unknown): string {
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return JSON.stringify(value) ?? String(value);
|
|
89
|
+
} catch {
|
|
90
|
+
return String(value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function truncationMarker(droppedChars: number): string {
|
|
95
|
+
return `…[${droppedChars} chars truncated — page with after/limit on session_events, or read the session notebook for the full content]`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function clampString(value: string, maxChars: number): string {
|
|
99
|
+
if (value.length <= maxChars) {
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
// Keep a head and a small tail of the field so both the start and the end
|
|
103
|
+
// (often the most diagnostic part of a tool output / error) survive.
|
|
104
|
+
const dropped = value.length - maxChars;
|
|
105
|
+
const headChars = Math.max(0, Math.floor(maxChars * 0.7));
|
|
106
|
+
const tailChars = Math.max(0, maxChars - headChars);
|
|
107
|
+
const head = value.slice(0, headChars);
|
|
108
|
+
const tail = tailChars > 0 ? value.slice(value.length - tailChars) : "";
|
|
109
|
+
return `${head}${truncationMarker(dropped)}${tail}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recursively clamp any over-budget string or nested value inside a payload.
|
|
114
|
+
* Strings longer than `perFieldChars` are head+tail clamped. Nested objects /
|
|
115
|
+
* arrays whose serialized form exceeds `perFieldChars` are recursed into so the
|
|
116
|
+
* clamp lands on the actual fat leaf; if recursion cannot shrink them enough
|
|
117
|
+
* (e.g. thousands of tiny fields), the whole branch is replaced by its clamped
|
|
118
|
+
* serialization. Plain scalars pass through untouched. A depth guard makes the
|
|
119
|
+
* walk safe against pathological / cyclic structures.
|
|
120
|
+
*/
|
|
121
|
+
export function capPayloadValue(value: unknown, perFieldChars: number, depth = 0): unknown {
|
|
122
|
+
if (typeof value === "string") {
|
|
123
|
+
return clampString(value, perFieldChars);
|
|
124
|
+
}
|
|
125
|
+
if (value === null || typeof value !== "object") {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
// Guard against pathological / cyclic structures: past a reasonable depth,
|
|
129
|
+
// collapse to a clamped serialization.
|
|
130
|
+
if (depth >= 8) {
|
|
131
|
+
return clampString(safeStringify(value), perFieldChars);
|
|
132
|
+
}
|
|
133
|
+
const serializedLength = safeStringify(value).length;
|
|
134
|
+
if (serializedLength <= perFieldChars) {
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
const mapped = value.map((entry) => capPayloadValue(entry, perFieldChars, depth + 1));
|
|
139
|
+
if (safeStringify(mapped).length <= perFieldChars * 2) {
|
|
140
|
+
return mapped;
|
|
141
|
+
}
|
|
142
|
+
return clampString(safeStringify(value), perFieldChars);
|
|
143
|
+
}
|
|
144
|
+
const out: Record<string, unknown> = {};
|
|
145
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
146
|
+
out[key] = capPayloadValue(entry, perFieldChars, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
// If recursion still left the object fat (many small fields), fall back to a
|
|
149
|
+
// clamped serialization so the page budget is respected.
|
|
150
|
+
if (safeStringify(out).length <= perFieldChars * 4) {
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
return clampString(safeStringify(value), perFieldChars);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function capEventPayload(event: SessionEvent, perFieldChars: number): SessionEvent {
|
|
157
|
+
const cappedPayload = capPayloadValue(event.payload, perFieldChars);
|
|
158
|
+
if (cappedPayload === event.payload) {
|
|
159
|
+
return event;
|
|
160
|
+
}
|
|
161
|
+
return { ...event, payload: cappedPayload };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type CappedEventPage = {
|
|
165
|
+
events: SessionEvent[];
|
|
166
|
+
// The real highest `sequence` among the events the DB returned, so the caller
|
|
167
|
+
// can advance the cursor correctly even when the middle was dropped. Null
|
|
168
|
+
// when the page was empty.
|
|
169
|
+
nextAfter: number | null;
|
|
170
|
+
truncated: boolean;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build a synthetic marker event that stands in for the dropped middle. It is
|
|
175
|
+
* NOT a real persisted event; its `id` is the zero UUID and its sequence sits
|
|
176
|
+
* between the kept head and tail so ordering by sequence stays monotonic. It
|
|
177
|
+
* never participates in pagination (the caller derives `nextAfter` from the
|
|
178
|
+
* real events, not this marker). Typed `session.status.changed` so the
|
|
179
|
+
* synthetic event still validates against the `SessionEvent` contract.
|
|
180
|
+
*/
|
|
181
|
+
function buildTruncationEvent(
|
|
182
|
+
template: SessionEvent,
|
|
183
|
+
droppedCount: number,
|
|
184
|
+
firstDroppedSequence: number,
|
|
185
|
+
lastDroppedSequence: number,
|
|
186
|
+
markerSequence: number,
|
|
187
|
+
): SessionEvent {
|
|
188
|
+
return {
|
|
189
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
190
|
+
workspaceId: template.workspaceId,
|
|
191
|
+
sessionId: template.sessionId,
|
|
192
|
+
sequence: markerSequence,
|
|
193
|
+
type: "session.status.changed",
|
|
194
|
+
payload: {
|
|
195
|
+
_truncated: true,
|
|
196
|
+
note: `${droppedCount} event(s) (sequence ${firstDroppedSequence}–${lastDroppedSequence}) omitted from this monitoring view to keep the response bounded. Page the gap with session_events after=${firstDroppedSequence - 1} limit=… if you need them verbatim, or read the worker's session notebook.`,
|
|
197
|
+
droppedCount,
|
|
198
|
+
omittedSequenceRange: [firstDroppedSequence, lastDroppedSequence],
|
|
199
|
+
},
|
|
200
|
+
occurredAt: template.occurredAt,
|
|
201
|
+
clientEventId: null,
|
|
202
|
+
turnId: null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply per-event field trim then, if the page is still over budget, keep a
|
|
208
|
+
* head and a tail of events and drop the middle behind a marker. `events` is
|
|
209
|
+
* assumed oldest-first (as `listSessionEvents` returns).
|
|
210
|
+
*/
|
|
211
|
+
export function capEventPage(events: SessionEvent[], config: EventCapConfig = DEFAULT_EVENT_CAP): CappedEventPage {
|
|
212
|
+
const realLast = events[events.length - 1];
|
|
213
|
+
const nextAfter = realLast ? realLast.sequence : null;
|
|
214
|
+
|
|
215
|
+
const trimmed = events.map((event) => capEventPayload(event, config.perFieldChars));
|
|
216
|
+
|
|
217
|
+
let runningTokens = 0;
|
|
218
|
+
let overBudget = false;
|
|
219
|
+
for (const event of trimmed) {
|
|
220
|
+
runningTokens += estimateValueTokens(event);
|
|
221
|
+
if (runningTokens > config.pageTokenBudget) {
|
|
222
|
+
overBudget = true;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const keepCount = config.headEvents + config.tailEvents;
|
|
228
|
+
if (!overBudget || trimmed.length <= keepCount + 1) {
|
|
229
|
+
return { events: trimmed, nextAfter, truncated: overBudget && trimmed.length > keepCount + 1 };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const head = trimmed.slice(0, config.headEvents);
|
|
233
|
+
const tail = trimmed.slice(trimmed.length - config.tailEvents);
|
|
234
|
+
const droppedStart = config.headEvents;
|
|
235
|
+
const droppedEnd = trimmed.length - config.tailEvents - 1;
|
|
236
|
+
const droppedCount = droppedEnd - droppedStart + 1;
|
|
237
|
+
const firstDroppedSequence = trimmed[droppedStart]!.sequence;
|
|
238
|
+
const lastDroppedSequence = trimmed[droppedEnd]!.sequence;
|
|
239
|
+
// Marker sequence sits between the kept head and tail; reusing the last head
|
|
240
|
+
// sequence keeps the returned page monotonic non-decreasing by sequence.
|
|
241
|
+
const markerSequence = head[head.length - 1]!.sequence;
|
|
242
|
+
const marker = buildTruncationEvent(
|
|
243
|
+
realLast!,
|
|
244
|
+
droppedCount,
|
|
245
|
+
firstDroppedSequence,
|
|
246
|
+
lastDroppedSequence,
|
|
247
|
+
markerSequence,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
events: [...head, marker, ...tail],
|
|
252
|
+
nextAfter,
|
|
253
|
+
truncated: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clamp a single session-detail object for `session_get`. Only the unbounded
|
|
259
|
+
* agent-controlled fields (`metadata`, and defensively `initialMessage`) can
|
|
260
|
+
* grow large; everything else is small and structural. Returns a shallow copy
|
|
261
|
+
* with those fields capped when over budget, otherwise the original reference.
|
|
262
|
+
*/
|
|
263
|
+
export function capSessionDetail<T extends { metadata?: unknown; initialMessage?: unknown }>(
|
|
264
|
+
session: T,
|
|
265
|
+
perFieldChars: number = DEFAULT_SESSION_DETAIL_CHARS,
|
|
266
|
+
): T {
|
|
267
|
+
let changed = false;
|
|
268
|
+
const out: T = { ...session };
|
|
269
|
+
if (session.metadata !== undefined) {
|
|
270
|
+
const capped = capPayloadValue(session.metadata, perFieldChars);
|
|
271
|
+
if (capped !== session.metadata) {
|
|
272
|
+
(out as { metadata?: unknown }).metadata = capped;
|
|
273
|
+
changed = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (typeof session.initialMessage === "string" && session.initialMessage.length > perFieldChars) {
|
|
277
|
+
(out as { initialMessage?: unknown }).initialMessage = clampString(session.initialMessage, perFieldChars);
|
|
278
|
+
changed = true;
|
|
279
|
+
}
|
|
280
|
+
return changed ? out : session;
|
|
281
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CreateApiKeyRequest, CreateApiKeyResponse, Permission } from "@opengeni/contracts";
|
|
2
|
+
import { createApiKey, listApiKeys, revokeApiKey } from "@opengeni/db";
|
|
3
|
+
import { zValidator } from "@hono/zod-validator";
|
|
4
|
+
import type { Hono } from "hono";
|
|
5
|
+
import { HTTPException } from "hono/http-exception";
|
|
6
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
7
|
+
import { requireAccessGrant } from "@opengeni/core";
|
|
8
|
+
import { requireLimit } from "@opengeni/core";
|
|
9
|
+
|
|
10
|
+
export function registerApiKeyRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
11
|
+
app.get("/v1/workspaces/:workspaceId/api-keys", async (c) => {
|
|
12
|
+
const workspaceId = c.req.param("workspaceId");
|
|
13
|
+
await requireAccessGrant(c, deps, workspaceId, "api_keys:manage");
|
|
14
|
+
return c.json({ apiKeys: await listApiKeys(deps.db, workspaceId) });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
app.post("/v1/workspaces/:workspaceId/api-keys", zValidator("json", CreateApiKeyRequest.omit({ workspaceId: true })), async (c) => {
|
|
18
|
+
const workspaceId = c.req.param("workspaceId");
|
|
19
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "api_keys:manage");
|
|
20
|
+
const body = c.req.valid("json");
|
|
21
|
+
const permissions: Permission[] = body.permissions.length > 0 ? body.permissions as Permission[] : ["workspace:read"];
|
|
22
|
+
ensureDelegablePermissions(grant.permissions, permissions);
|
|
23
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "api_key:create", quantity: 1 });
|
|
24
|
+
const token = generateApiKeyToken();
|
|
25
|
+
const prefix = token.slice(0, 14);
|
|
26
|
+
const apiKey = await createApiKey(deps.db, {
|
|
27
|
+
accountId: grant.accountId,
|
|
28
|
+
workspaceId: grant.workspaceId,
|
|
29
|
+
name: body.name,
|
|
30
|
+
prefix,
|
|
31
|
+
keyHash: await sha256Hex(token),
|
|
32
|
+
permissions,
|
|
33
|
+
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
|
34
|
+
});
|
|
35
|
+
return c.json(CreateApiKeyResponse.parse({ apiKey, token }), 201);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.delete("/v1/workspaces/:workspaceId/api-keys/:apiKeyId", async (c) => {
|
|
39
|
+
const workspaceId = c.req.param("workspaceId");
|
|
40
|
+
await requireAccessGrant(c, deps, workspaceId, "api_keys:manage");
|
|
41
|
+
return c.json(await revokeApiKey(deps.db, workspaceId, c.req.param("apiKeyId")));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureDelegablePermissions(grantPermissions: Permission[], requested: Permission[]): void {
|
|
46
|
+
if (grantPermissions.includes("workspace:admin")) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const missing = requested.filter((permission) => !grantPermissions.includes(permission));
|
|
50
|
+
if (missing.length > 0) {
|
|
51
|
+
throw new HTTPException(403, { message: `cannot delegate missing permissions: ${missing.join(", ")}` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function generateApiKeyToken(): string {
|
|
56
|
+
const bytes = new Uint8Array(32);
|
|
57
|
+
crypto.getRandomValues(bytes);
|
|
58
|
+
const secret = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
59
|
+
return `ogk_${secret}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
63
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
64
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
65
|
+
}
|