nexus-agentd 0.2.6 → 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.
Files changed (56) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +103 -14
  3. package/dist/a2a/runtime.d.ts +4 -1
  4. package/dist/a2a/runtime.js +213 -10
  5. package/dist/acp/runtime.js +13 -6
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/config.js +45 -5
  10. package/dist/control-plane-validation.d.ts +54 -0
  11. package/dist/control-plane-validation.js +152 -0
  12. package/dist/control-plane.d.ts +9 -32
  13. package/dist/control-plane.js +41 -157
  14. package/dist/drivers/index.js +12 -0
  15. package/dist/drivers/pi.js +1 -1
  16. package/dist/drivers/stdio.d.ts +4 -1
  17. package/dist/drivers/stdio.js +135 -19
  18. package/dist/events.d.ts +9 -0
  19. package/dist/events.js +17 -4
  20. package/dist/index.js +10 -1
  21. package/dist/process-tree.js +15 -6
  22. package/dist/run-store.d.ts +52 -7
  23. package/dist/run-store.js +178 -22
  24. package/dist/server/auth-policy.d.ts +27 -0
  25. package/dist/server/auth-policy.js +143 -0
  26. package/dist/server/connection.d.ts +8 -0
  27. package/dist/server/connection.js +67 -0
  28. package/dist/server/diagnostics.d.ts +40 -0
  29. package/dist/server/diagnostics.js +130 -0
  30. package/dist/server/metrics.d.ts +51 -0
  31. package/dist/server/metrics.js +56 -0
  32. package/dist/server/quota.d.ts +59 -0
  33. package/dist/server/quota.js +176 -0
  34. package/dist/server/validation.d.ts +29 -0
  35. package/dist/server/validation.js +221 -0
  36. package/dist/server.d.ts +3 -0
  37. package/dist/server.js +682 -418
  38. package/dist/session/admin-runs.d.ts +73 -0
  39. package/dist/session/admin-runs.js +297 -0
  40. package/dist/session/admission.d.ts +32 -0
  41. package/dist/session/admission.js +62 -0
  42. package/dist/session/readiness.d.ts +33 -0
  43. package/dist/session/readiness.js +174 -0
  44. package/dist/session.d.ts +58 -6
  45. package/dist/session.js +244 -109
  46. package/dist/sse-writer.d.ts +7 -0
  47. package/dist/sse-writer.js +60 -0
  48. package/dist/types.d.ts +44 -2
  49. package/dist/types.js +1 -0
  50. package/dist/webui/app/sources.js +16 -8
  51. package/dist/webui/index.js +11 -11
  52. package/dist/webui/markup.js +2 -0
  53. package/dist/webui/styles.js +46 -1
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +53 -34
  56. package/package.json +3 -2
package/dist/server.js CHANGED
@@ -1,18 +1,73 @@
1
- import { randomUUID, timingSafeEqual } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import http from 'node:http';
3
3
  import { AdminSessionStore } from './auth.js';
4
+ import { streamSessionEvents } from './server/connection.js';
5
+ import { DiagnosticConcurrencyError, DiagnosticRunner } from './server/diagnostics.js';
6
+ import { ADMIN_COOKIE, adminCookie, assertAgentScope, assertConsoleHost, assertTrustedOrigin, authenticateAdmin, authenticateApiKey, clearAdminCookie, cookieValue, isInitialized, isMutating, needsAdminSetup, remoteKey, stringHeader } from './server/auth-policy.js';
7
+ import { GatewayMetrics } from './server/metrics.js';
8
+ import { QuotaExceededError, QuotaManager } from './server/quota.js';
9
+ import { RequestError, assertEmptyJsonBody, assertJsonContentType, assertOnlyKeys, boundedLimit, cleanQuery, decodeHeader, normalizePublishPaths, normalizedMediaType, optionalAttachmentIds, optionalBoolean, optionalNumber, optionalPendingAction, optionalRawString, optionalRunState, optionalString, readBytesBody, readJsonBody, requiredInteger, requiredMessage, requiredRawString, requiredString, requiredStringArray } from './server/validation.js';
4
10
  import { ControlPlaneError } from './control-plane.js';
5
11
  import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
6
12
  import { redirectToAgentdWebUi, writeAgentdWebUi, writeAgentdWebUiModule } from './webui/index.js';
7
- const ADMIN_COOKIE = 'agent_nexus_admin';
13
+ export { QuotaExceededError, QuotaManager, quotaLimitsFromConfig } from './server/quota.js';
14
+ export { DiagnosticRunner, DiagnosticConcurrencyError } from './server/diagnostics.js';
15
+ export { GatewayMetrics } from './server/metrics.js';
8
16
  export function createAgentdServer(config, sessions, controlPlane) {
9
17
  const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
10
18
  const loginLimiter = new FailureRateLimiter(8, 60_000, 5 * 60_000);
11
19
  const apiLimiter = new FailureRateLimiter(30, 60_000, 60_000);
20
+ const setupLimiter = new FailureRateLimiter(8, 60_000, 60_000);
21
+ const revealLimiter = new FailureRateLimiter(20, 60_000, 60_000);
22
+ const quota = new QuotaManager(config);
23
+ const metrics = new GatewayMetrics();
24
+ const diagnostics = new DiagnosticRunner(2);
12
25
  let sseConnections = 0;
26
+ const sessionReservations = new Map();
27
+ const uploadReservations = new Map();
28
+ const runReservations = new Map();
29
+ // SessionManager emits terminal run transitions independently of HTTP
30
+ // requests. Release the per-key running slot at that boundary so an
31
+ // asynchronous completion cannot strand a quota reservation until the
32
+ // next poll.
33
+ const releaseRunReservationById = (sessionId) => {
34
+ const release = runReservations.get(sessionId);
35
+ if (release)
36
+ release();
37
+ else
38
+ quota.finishRun(sessionId);
39
+ };
40
+ const releaseSessionReservationsById = (sessionId) => {
41
+ const releaseSession = sessionReservations.get(sessionId);
42
+ if (releaseSession) {
43
+ sessionReservations.delete(sessionId);
44
+ releaseSession();
45
+ }
46
+ const releaseUpload = uploadReservations.get(sessionId);
47
+ if (releaseUpload) {
48
+ uploadReservations.delete(sessionId);
49
+ releaseUpload();
50
+ }
51
+ releaseRunReservationById(sessionId);
52
+ };
53
+ const subscribeLifecycle = sessions.subscribeLifecycle;
54
+ const unsubscribeLifecycle = typeof subscribeLifecycle === 'function'
55
+ ? subscribeLifecycle.call(sessions, (event) => {
56
+ if (event.type === 'run_updated' && event.state && isTerminalState(event.state)) {
57
+ releaseRunReservationById(event.sessionId);
58
+ }
59
+ if (event.type === 'session_closed') {
60
+ releaseSessionReservationsById(event.sessionId);
61
+ }
62
+ })
63
+ : undefined;
13
64
  const server = http.createServer((request, response) => {
14
65
  const requestId = randomUUID();
66
+ metrics.requestStarted();
15
67
  response.setHeader('X-Request-Id', requestId);
68
+ response.once('finish', () => {
69
+ metrics.responseFinished(response.statusCode, response.statusCode === 429);
70
+ });
16
71
  void handleRequest({
17
72
  config,
18
73
  sessions,
@@ -20,23 +75,39 @@ export function createAgentdServer(config, sessions, controlPlane) {
20
75
  adminSessions,
21
76
  loginLimiter,
22
77
  apiLimiter,
78
+ setupLimiter,
79
+ revealLimiter,
80
+ quota,
81
+ metrics,
82
+ diagnostics,
83
+ sessionReservations,
84
+ uploadReservations,
85
+ runReservations,
86
+ currentSse: () => sseConnections,
23
87
  request,
24
88
  response,
25
- acquireSse() {
26
- if (sseConnections >= (config.maxSseConnections || 128))
27
- return false;
89
+ acquireSse(keyId) {
90
+ if (sseConnections >= (config.maxSseConnections || 128)) {
91
+ throw new QuotaExceededError('sse', config.maxSseConnections || 128, sseConnections, 1_000);
92
+ }
93
+ const releaseQuota = quota.reserveSse(keyId);
28
94
  sseConnections += 1;
29
- return true;
95
+ return () => {
96
+ releaseQuota();
97
+ sseConnections = Math.max(0, sseConnections - 1);
98
+ };
30
99
  },
31
- releaseSse() {
32
- sseConnections = Math.max(0, sseConnections - 1);
33
- }
34
100
  }).catch((error) => {
35
101
  if (response.headersSent) {
36
102
  response.destroy(error instanceof Error ? error : undefined);
37
103
  return;
38
104
  }
39
105
  const status = errorStatus(error);
106
+ if (error instanceof QuotaExceededError || error instanceof DiagnosticConcurrencyError) {
107
+ const retryAfterMs = error.retryAfterMs;
108
+ response.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
109
+ response.setHeader('X-Quota-Type', error instanceof QuotaExceededError ? error.quota : 'diagnostics');
110
+ }
40
111
  if (status >= 500) {
41
112
  console.error(JSON.stringify({
42
113
  level: 'error',
@@ -48,22 +119,32 @@ export function createAgentdServer(config, sessions, controlPlane) {
48
119
  stack: error instanceof Error ? error.stack : undefined
49
120
  }));
50
121
  }
51
- writeJson(response, status, {
52
- error: status >= 500 ? 'Internal server error' : errorMessage(error),
53
- requestId
54
- });
122
+ writeError(response, status, error, requestId);
55
123
  });
56
124
  });
57
125
  server.requestTimeout = config.requestTimeoutMs || 30_000;
58
126
  server.headersTimeout = Math.min(server.requestTimeout, 20_000);
59
127
  server.keepAliveTimeout = 5_000;
60
128
  server.maxConnections = config.maxConnections || 256;
61
- server.once('close', () => adminSessions.clear());
129
+ server.once('close', () => {
130
+ unsubscribeLifecycle?.();
131
+ for (const sessionId of new Set([
132
+ ...sessionReservations.keys(),
133
+ ...uploadReservations.keys(),
134
+ ...runReservations.keys()
135
+ ])) {
136
+ releaseSessionReservationsById(sessionId);
137
+ }
138
+ adminSessions.clear();
139
+ });
62
140
  return server;
63
141
  }
64
142
  async function handleRequest(context) {
65
143
  const { config, sessions, controlPlane, adminSessions, loginLimiter, apiLimiter, request, response } = context;
66
144
  const url = new URL(request.url || '/', 'http://localhost');
145
+ if (url.pathname === '/' || url.pathname.startsWith('/ui') || url.pathname.startsWith('/v1/admin/') || url.pathname.startsWith('/v1/bootstrap/')) {
146
+ assertConsoleHost(request, config);
147
+ }
67
148
  if (url.pathname === '/' && request.method === 'GET') {
68
149
  redirectToAgentdWebUi(response);
69
150
  return;
@@ -95,9 +176,17 @@ async function handleRequest(context) {
95
176
  if (!controlPlane)
96
177
  throw new RequestError(404, 'Control plane is unavailable');
97
178
  assertJsonContentType(request);
98
- assertTrustedOrigin(request, true);
179
+ assertTrustedOrigin(request, config);
180
+ if (!needsAdminSetup(config, controlPlane))
181
+ throw new RequestError(409, 'Console setup is already complete');
182
+ const rateKey = remoteKey(request);
183
+ context.setupLimiter.assertAllowed(rateKey);
99
184
  const body = await readJsonBody(request, config.maxRequestBytes);
100
- assertOnlyKeys(body, ['password', 'confirmPassword']);
185
+ assertOnlyKeys(body, ['password', 'confirmPassword', 'setupToken']);
186
+ if (!controlPlane.verifySetupToken(optionalRawString(body.setupToken, 'setupToken') || '')) {
187
+ context.setupLimiter.failure(rateKey);
188
+ throw new RequestError(403, 'Invalid setup token');
189
+ }
101
190
  writeJson(response, 201, await controlPlane.initializeAdminPassword(requiredRawString(body.password, 'password'), requiredRawString(body.confirmPassword, 'confirmPassword')));
102
191
  return;
103
192
  }
@@ -116,14 +205,16 @@ async function handleRequest(context) {
116
205
  throw new RequestError(428, 'Console setup is required');
117
206
  }
118
207
  assertJsonContentType(request);
119
- assertTrustedOrigin(request, true);
208
+ assertTrustedOrigin(request, config);
120
209
  const rateKey = remoteKey(request);
121
210
  loginLimiter.assertAllowed(rateKey);
122
211
  const body = await readJsonBody(request, config.maxRequestBytes);
123
212
  assertOnlyKeys(body, ['password']);
124
213
  const password = requiredRawString(body.password, 'password');
214
+ // Reserve the attempt before asynchronous scrypt, including concurrent requests.
215
+ loginLimiter.assertAllowed(rateKey);
216
+ loginLimiter.failure(rateKey);
125
217
  if (!(await controlPlane.verifyAdminPassword(password))) {
126
- loginLimiter.failure(rateKey);
127
218
  throw new RequestError(401, 'Invalid Console Password');
128
219
  }
129
220
  loginLimiter.success(rateKey);
@@ -133,7 +224,7 @@ async function handleRequest(context) {
133
224
  return;
134
225
  }
135
226
  if (url.pathname === '/v1/admin/auth/logout' && request.method === 'POST') {
136
- assertTrustedOrigin(request, true);
227
+ assertTrustedOrigin(request, config);
137
228
  const id = cookieValue(request, ADMIN_COOKIE);
138
229
  if (id)
139
230
  adminSessions.delete(id);
@@ -149,7 +240,7 @@ async function handleRequest(context) {
149
240
  throw new RequestError(404, 'Control plane is unavailable');
150
241
  authenticateAdmin(request, adminSessions);
151
242
  if (isMutating(request.method))
152
- assertTrustedOrigin(request, true);
243
+ assertTrustedOrigin(request, config);
153
244
  await handleAdminRoute(context, url);
154
245
  return;
155
246
  }
@@ -167,13 +258,34 @@ async function handleRequest(context) {
167
258
  });
168
259
  return;
169
260
  }
261
+ const dataArtifactMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/artifacts\/([^/]+)$/);
262
+ if (dataArtifactMatch && request.method === 'GET') {
263
+ const runId = decodeURIComponent(dataArtifactMatch[1]);
264
+ const artifactId = decodeURIComponent(dataArtifactMatch[2]);
265
+ const run = await getRunForDataPlane(sessions, runId, principal.id);
266
+ assertAgentScope(principal, run.agentId);
267
+ await writeRunArtifact(response, sessions, run, artifactId);
268
+ return;
269
+ }
170
270
  if (url.pathname === '/v1/sessions' && request.method === 'POST') {
171
271
  assertJsonContentType(request);
172
272
  const body = await readJsonBody(request, config.maxRequestBytes);
173
273
  assertOnlyKeys(body, ['agentId', 'workspace']);
174
274
  const agentId = requiredString(body.agentId, 'agentId');
175
275
  assertAgentScope(principal, agentId);
176
- writeJson(response, 201, await sessions.create(agentId, optionalString(body.workspace), principal.id));
276
+ // Reserve before the asynchronous startup path. The reservation is
277
+ // retained until the session is explicitly closed so a key cannot
278
+ // fill the process with terminal-but-held sessions.
279
+ const releaseSession = context.quota.reserveSession(principal.id);
280
+ try {
281
+ const created = await sessions.create(agentId, optionalString(body.workspace), principal.id);
282
+ context.sessionReservations.set(created.id, releaseSession);
283
+ writeJson(response, 201, created);
284
+ }
285
+ catch (error) {
286
+ releaseSession();
287
+ throw error;
288
+ }
177
289
  return;
178
290
  }
179
291
  const attachmentMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/attachments$/);
@@ -184,8 +296,35 @@ async function handleRequest(context) {
184
296
  throw new RequestError(404, 'Session not found');
185
297
  }
186
298
  assertAgentScope(principal, session.agentId);
187
- const bytes = await readBytesBody(request, config.maxAttachmentBytes || 32 * 1024 * 1024);
188
- writeJson(response, 201, sessions.addInputAttachment(sessionId, decodeHeader(request.headers['x-nexus-file-name']) || 'attachment', normalizedMediaType(request.headers['content-type']), bytes));
299
+ const declared = Number(request.headers['content-length']);
300
+ // When a length is supplied, reject before consuming the body. This
301
+ // keeps a rejected upload from needlessly buffering a request and
302
+ // makes quota failures deterministic under concurrent uploads.
303
+ const preflightUpload = Number.isFinite(declared) && declared > 0
304
+ ? context.quota.reserveUpload(principal.id, declared)
305
+ : undefined;
306
+ let releaseUpload;
307
+ try {
308
+ const bytes = await readBytesBody(request, config.maxAttachmentBytes || 32 * 1024 * 1024);
309
+ preflightUpload?.();
310
+ releaseUpload = context.quota.reserveUpload(principal.id, bytes.length);
311
+ const attachment = sessions.addInputAttachment(sessionId, decodeHeader(request.headers['x-nexus-file-name']) || 'attachment', normalizedMediaType(request.headers['content-type']), bytes);
312
+ const previous = context.uploadReservations.get(sessionId);
313
+ if (previous) {
314
+ // Compose releases so closing the session returns the entire
315
+ // retained upload budget exactly once.
316
+ context.uploadReservations.set(sessionId, composeReleases(previous, releaseUpload));
317
+ }
318
+ else {
319
+ context.uploadReservations.set(sessionId, releaseUpload);
320
+ }
321
+ writeJson(response, 201, attachment);
322
+ }
323
+ catch (error) {
324
+ preflightUpload?.();
325
+ releaseUpload?.();
326
+ throw error;
327
+ }
189
328
  return;
190
329
  }
191
330
  const resolveMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/requests\/([^/]+)\/resolve$/);
@@ -203,7 +342,7 @@ async function handleRequest(context) {
203
342
  const resolution = {
204
343
  requestId,
205
344
  message: optionalString(body.message),
206
- optionId: optionalString(body.optionId),
345
+ optionId: optionalRawString(body.optionId, 'optionId'),
207
346
  action: optionalPendingAction(body.action)
208
347
  };
209
348
  if (!resolution.message &&
@@ -211,7 +350,17 @@ async function handleRequest(context) {
211
350
  !resolution.action) {
212
351
  throw new RequestError(400, 'A pending response is required');
213
352
  }
214
- writeJson(response, 202, await sessions.resolvePending(sessionId, resolution, optionalAttachmentIds(body.attachments)));
353
+ const releaseRun = reserveRunIfNeeded(context, principal.id, sessionId, session.state);
354
+ try {
355
+ const updated = await sessions.resolvePending(sessionId, resolution, optionalAttachmentIds(body.attachments));
356
+ if (isTerminalState(updated.state))
357
+ releaseRunReservation(context, sessionId);
358
+ writeJson(response, 202, updated);
359
+ }
360
+ catch (error) {
361
+ releaseRun?.();
362
+ throw error;
363
+ }
215
364
  return;
216
365
  }
217
366
  const publishMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/artifacts\/publish$/);
@@ -243,37 +392,54 @@ async function handleRequest(context) {
243
392
  throw new RequestError(404, 'Session not found');
244
393
  }
245
394
  assertAgentScope(principal, session.agentId);
395
+ reconcileRunReservation(context, principal.id, sessionId, session.state);
246
396
  if (!action && request.method === 'GET') {
247
397
  writeJson(response, 200, session);
248
398
  return;
249
399
  }
250
400
  if (!action && request.method === 'DELETE') {
251
- writeJson(response, 200, await sessions.close(sessionId));
401
+ const closed = await sessions.close(sessionId);
402
+ releaseSessionReservations(context, sessionId);
403
+ releaseRunReservation(context, sessionId);
404
+ writeJson(response, 200, closed);
252
405
  return;
253
406
  }
254
407
  if (action === 'message' && request.method === 'POST') {
255
408
  assertJsonContentType(request);
256
409
  const body = await readJsonBody(request, config.maxRequestBytes);
257
410
  assertOnlyKeys(body, ['message', 'attachments']);
258
- writeJson(response, 202, await sessions.message(sessionId, requiredMessage(body.message), optionalAttachmentIds(body.attachments)));
411
+ const releaseRun = reserveRunIfNeeded(context, principal.id, sessionId, session.state);
412
+ try {
413
+ const updated = await sessions.message(sessionId, requiredMessage(body.message), optionalAttachmentIds(body.attachments));
414
+ if (isTerminalState(updated.state))
415
+ releaseRunReservation(context, sessionId);
416
+ writeJson(response, 202, updated);
417
+ }
418
+ catch (error) {
419
+ releaseRun?.();
420
+ throw error;
421
+ }
259
422
  return;
260
423
  }
261
424
  if (action === 'cancel' && request.method === 'POST') {
262
425
  await assertEmptyJsonBody(request, config.maxRequestBytes);
263
- writeJson(response, 200, await sessions.cancel(sessionId));
426
+ const canceled = await sessions.cancel(sessionId);
427
+ releaseRunReservation(context, sessionId);
428
+ writeJson(response, 200, canceled);
264
429
  return;
265
430
  }
266
431
  if (action === 'events' && request.method === 'GET') {
267
- if (!context.acquireSse()) {
432
+ const releaseSse = context.acquireSse(principal.id);
433
+ if (!releaseSse) {
268
434
  throw new RequestError(429, 'SSE connection capacity has been reached');
269
435
  }
270
- streamEvents(request, response, sessions, sessionId, url.searchParams.get('after') || stringHeader(request.headers['last-event-id']), context.releaseSse);
436
+ streamSessionEvents(request, response, sessions, sessionId, url.searchParams.get('after') ?? (stringHeader(request.headers['last-event-id']) || undefined), releaseSse, (close) => controlPlane?.watchApiKey(principal.id, stringHeader(request.headers.authorization).slice(7), session.agentId, close) || (() => { }));
271
437
  return;
272
438
  }
273
439
  throw new RequestError(405, 'Method not allowed');
274
440
  }
275
441
  async function handleAdminRoute(context, url) {
276
- const { config, sessions, controlPlane, adminSessions, request, response } = context;
442
+ const { config, sessions, controlPlane, adminSessions, request, response, metrics, diagnostics } = context;
277
443
  if (!controlPlane)
278
444
  throw new RequestError(404, 'Control plane is unavailable');
279
445
  if (url.pathname === '/v1/admin/overview' && request.method === 'GET') {
@@ -281,6 +447,36 @@ async function handleAdminRoute(context, url) {
281
447
  writeJson(response, 200, { agents, sessions: sessions.count() });
282
448
  return;
283
449
  }
450
+ if (url.pathname === '/v1/admin/metrics' && request.method === 'GET') {
451
+ const all = sessions.listRuns({ limit: 1 });
452
+ const actionStates = new Set(['failed', 'input_required', 'permission_required']);
453
+ const recent = new Map();
454
+ for (const state of actionStates) {
455
+ for (const run of sessions.listRuns({ state: state, limit: 5 }).runs) {
456
+ recent.set(run.id, run);
457
+ }
458
+ }
459
+ const recentRuns = Array.from(recent.values())
460
+ .sort((left, right) => right.updatedAt - left.updatedAt)
461
+ .slice(0, 5);
462
+ const baseStats = all.stats || { active: 0, completed: 0, failed: 0 };
463
+ const runStats = {
464
+ active: Number(baseStats.active) || 0,
465
+ completed: Number(baseStats.completed) || 0,
466
+ failed: Number(baseStats.failed) || 0,
467
+ total: Number(all.total) || 0
468
+ };
469
+ writeJson(response, 200, await metrics.snapshot({
470
+ sessions: sessions.count(),
471
+ currentSse: currentSseCount(context),
472
+ runs: runStats,
473
+ recentRuns,
474
+ storagePath: config.workspaceRoots[0],
475
+ storageMetrics: sessions.getStorageMetrics?.(),
476
+ quotas: context.quota
477
+ }));
478
+ return;
479
+ }
284
480
  if (url.pathname === '/v1/admin/config' && request.method === 'GET') {
285
481
  writeJson(response, 200, controlPlane.snapshot());
286
482
  return;
@@ -299,13 +495,40 @@ async function handleAdminRoute(context, url) {
299
495
  sessionId: cleanQuery(url.searchParams.get('sessionId')),
300
496
  state,
301
497
  query: cleanQuery(url.searchParams.get('q')),
498
+ offset: boundedOffset(url.searchParams.get('offset')),
302
499
  limit: boundedLimit(url.searchParams.get('limit'))
303
500
  }));
304
501
  return;
305
502
  }
503
+ const runActionMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)\/(cancel|retry|respond)$/);
504
+ if (runActionMatch) {
505
+ await handleAdminRunAction(context, decodeURIComponent(runActionMatch[1]), runActionMatch[2]);
506
+ return;
507
+ }
508
+ const adminArtifactMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)\/artifacts\/([^/]+)$/);
509
+ if (adminArtifactMatch && request.method === 'GET') {
510
+ const run = sessions.getRun(decodeURIComponent(adminArtifactMatch[1]));
511
+ await writeRunArtifact(response, sessions, run, decodeURIComponent(adminArtifactMatch[2]));
512
+ return;
513
+ }
306
514
  const runMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)$/);
307
515
  if (runMatch && request.method === 'GET') {
308
- writeJson(response, 200, sessions.getRun(decodeURIComponent(runMatch[1])));
516
+ const runId = decodeURIComponent(runMatch[1]);
517
+ const detail = sessions.getRun(runId);
518
+ writeJson(response, 200, {
519
+ ...detail,
520
+ controls: buildRunControls(sessions, detail)
521
+ });
522
+ return;
523
+ }
524
+ const diagnosticMatch = url.pathname.match(/^\/v1\/admin\/agents\/([^/]+)\/diagnostics$/);
525
+ if (diagnosticMatch && request.method === 'POST') {
526
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
527
+ const agentId = decodeURIComponent(diagnosticMatch[1]);
528
+ const configured = controlPlane.snapshot().agents.find((agent) => agent.id === agentId);
529
+ const workspace = configured?.workspace;
530
+ const result = await diagnostics.run(sessions, agentId, workspace, configured);
531
+ writeJson(response, result.status === 'busy' ? 429 : 200, result);
309
532
  return;
310
533
  }
311
534
  if (url.pathname === '/v1/admin/config/workspace-roots' && request.method === 'PUT') {
@@ -351,7 +574,12 @@ async function handleAdminRoute(context, url) {
351
574
  const action = keyMatch[2];
352
575
  if (action === 'reveal' && request.method === 'POST') {
353
576
  await assertEmptyJsonBody(request, config.maxRequestBytes);
354
- writeJson(response, 200, controlPlane.revealApiKey(id));
577
+ const rateKey = remoteKey(request);
578
+ context.revealLimiter.assertAllowed(rateKey);
579
+ context.revealLimiter.failure(rateKey);
580
+ const result = controlPlane.revealApiKey(id);
581
+ console.info(JSON.stringify({ level: 'info', event: 'api_key_revealed', keyId: id, requestId: response.getHeader('X-Request-Id'), remoteAddress: rateKey }));
582
+ writeJson(response, 200, result);
355
583
  return;
356
584
  }
357
585
  if (action === 'regenerate' && request.method === 'POST') {
@@ -389,6 +617,381 @@ async function handleAdminRoute(context, url) {
389
617
  }
390
618
  throw new RequestError(404, 'Route not found');
391
619
  }
620
+ async function handleAdminRunAction(context, runId, action) {
621
+ const { config, sessions, request, response } = context;
622
+ const requestId = String(response.getHeader('X-Request-Id') || '');
623
+ let detail;
624
+ let sessionId = '';
625
+ let agentId = '';
626
+ let ownerKeyId;
627
+ try {
628
+ if (request.method !== 'POST')
629
+ throw new RequestError(405, 'Method not allowed');
630
+ detail = sessions.getRun(runId);
631
+ sessionId = detail.sessionId;
632
+ agentId = detail.agentId;
633
+ ownerKeyId = runOwnerKeyId(sessions, runId);
634
+ const controls = buildRunControls(sessions, detail);
635
+ if (action === 'cancel' && !controls.canCancel) {
636
+ throw new RequestError(409, controls.unavailableReason || 'Run cannot be canceled');
637
+ }
638
+ if (action === 'retry' && !controls.canRetry) {
639
+ throw new RequestError(409, controls.unavailableReason || 'Run cannot be retried');
640
+ }
641
+ if (action === 'cancel' || action === 'retry') {
642
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
643
+ }
644
+ let input;
645
+ if (action === 'respond')
646
+ input = await readAdminRunResponse(request, config.maxRequestBytes);
647
+ // AdminRunManager calls this factory only after its synchronous retry
648
+ // deduplication gate accepts a genuinely new retry. This keeps a
649
+ // completed duplicate from consuming either session or run quota.
650
+ const reserveRetry = action === 'retry'
651
+ ? (keyId) => reserveAdminRetry(context, keyId)
652
+ : undefined;
653
+ const result = await invokeRunAction(sessions, action, runId, sessionId, input, reserveRetry);
654
+ const resultRunId = action === 'retry'
655
+ ? actionResultString(result, 'runId') || actionResultRecord(result, 'run')?.id || runId
656
+ : runId;
657
+ const resultSessionId = action === 'retry'
658
+ ? actionResultString(result, 'sessionId') || actionResultRecord(result, 'session')?.id || ''
659
+ : sessionId;
660
+ if (!resultSessionId && action === 'retry') {
661
+ throw new RequestError(502, 'Retry did not return a session');
662
+ }
663
+ const state = actionResultString(result, 'state') ||
664
+ actionResultRecord(result, 'session')?.state ||
665
+ actionResultRecord(result, 'run')?.state;
666
+ if (action === 'cancel' || state === 'canceled')
667
+ releaseRunReservation(context, sessionId);
668
+ if (action === 'retry' && state && isTerminalState(state) && resultSessionId) {
669
+ releaseRunReservation(context, resultSessionId);
670
+ }
671
+ auditAdminRunAction({
672
+ action,
673
+ runId,
674
+ sessionId,
675
+ agentId,
676
+ keyId: ownerKeyId,
677
+ requestId,
678
+ result: 'accepted'
679
+ });
680
+ if (action === 'retry') {
681
+ writeJson(response, 202, { runId: resultRunId, sessionId: resultSessionId });
682
+ }
683
+ else {
684
+ writeJson(response, 202, {
685
+ runId,
686
+ sessionId,
687
+ ...(state ? { state } : {}),
688
+ result: 'accepted'
689
+ });
690
+ }
691
+ }
692
+ catch (error) {
693
+ auditAdminRunAction({
694
+ action,
695
+ runId,
696
+ sessionId,
697
+ agentId,
698
+ keyId: ownerKeyId,
699
+ requestId,
700
+ result: 'failed'
701
+ });
702
+ throw error;
703
+ }
704
+ }
705
+ async function readAdminRunResponse(request, maxBytes) {
706
+ assertJsonContentType(request);
707
+ const body = await readJsonBody(request, maxBytes);
708
+ assertOnlyKeys(body, ['requestId', 'message', 'optionId', 'action']);
709
+ const result = {
710
+ requestId: requiredString(body.requestId, 'requestId'),
711
+ message: optionalRawString(body.message, 'message'),
712
+ optionId: optionalRawString(body.optionId, 'optionId'),
713
+ action: optionalPendingAction(body.action)
714
+ };
715
+ if (!result.message?.trim() && !result.optionId && !result.action) {
716
+ throw new RequestError(400, 'A pending response is required');
717
+ }
718
+ return result;
719
+ }
720
+ async function invokeRunAction(sessions, action, runId, sessionId, input, reserveRetry) {
721
+ const target = sessions;
722
+ const candidates = action === 'cancel'
723
+ ? ['cancelRun', 'cancelByRunId']
724
+ : action === 'retry'
725
+ ? ['retryRun', 'retryByRunId']
726
+ : ['respondRun', 'respondByRunId'];
727
+ for (const name of candidates) {
728
+ if (typeof target[name] !== 'function')
729
+ continue;
730
+ if (action === 'respond')
731
+ return target[name].call(sessions, runId, input);
732
+ if (action === 'retry')
733
+ return target[name].call(sessions, runId, reserveRetry);
734
+ return target[name].call(sessions, runId);
735
+ }
736
+ // Cancellation and pending responses can be safely delegated through the
737
+ // existing SessionManager methods. Retry intentionally has no fallback:
738
+ // reconstructing a prompt here could duplicate a run or lose attachments.
739
+ if (action === 'cancel' && typeof target.cancel === 'function') {
740
+ return target.cancel.call(sessions, sessionId);
741
+ }
742
+ if (action === 'respond' && typeof target.resolvePending === 'function') {
743
+ return target.resolvePending.call(sessions, sessionId, input);
744
+ }
745
+ throw new RequestError(409, `Run ${action} is unavailable`);
746
+ }
747
+ function buildRunControls(sessions, detail) {
748
+ const target = sessions;
749
+ if (typeof target.getRunControls === 'function') {
750
+ try {
751
+ return structuredClone(target.getRunControls(detail.id));
752
+ }
753
+ catch {
754
+ // Fall through to the compatibility calculation for embedders
755
+ // that expose getRunControls only for newer run records.
756
+ }
757
+ }
758
+ let session;
759
+ try {
760
+ session = sessions.get(detail.sessionId);
761
+ }
762
+ catch {
763
+ session = undefined;
764
+ }
765
+ const active = isActiveState(detail.state);
766
+ const canCancel = active && (typeof target.cancelRun === 'function' ||
767
+ typeof target.cancelByRunId === 'function' ||
768
+ typeof target.cancel === 'function');
769
+ const canRetry = (detail.state === 'failed' || detail.state === 'canceled') && (typeof target.retryRun === 'function' || typeof target.retryByRunId === 'function');
770
+ const pendingRequest = session?.pendingRequest || detail.pendingRequest;
771
+ const unavailableReason = !session && active
772
+ ? 'Session is no longer available'
773
+ : active && !canCancel
774
+ ? 'Cancellation is unavailable'
775
+ : (detail.state === 'failed' || detail.state === 'canceled') && !canRetry
776
+ ? 'Retry is unavailable'
777
+ : undefined;
778
+ return {
779
+ canCancel,
780
+ canRetry,
781
+ ...(pendingRequest ? { pendingRequest: structuredClone(pendingRequest) } : {}),
782
+ ...(unavailableReason ? { unavailableReason } : {})
783
+ };
784
+ }
785
+ function runOwnerKeyId(sessions, runId) {
786
+ const target = sessions;
787
+ for (const name of ['getRunOwnerKeyId', 'runOwnerKeyId', 'ownerKeyForRun']) {
788
+ if (typeof target[name] !== 'function')
789
+ continue;
790
+ const value = target[name].call(sessions, runId);
791
+ if (typeof value === 'string' && value)
792
+ return value;
793
+ }
794
+ return undefined;
795
+ }
796
+ function actionResultString(value, key) {
797
+ return isRecord(value) && typeof value[key] === 'string' ? value[key] : undefined;
798
+ }
799
+ function actionResultRecord(value, key) {
800
+ return isRecord(value) && isRecord(value[key]) ? value[key] : undefined;
801
+ }
802
+ function auditAdminRunAction(input) {
803
+ console.info(JSON.stringify({
804
+ level: 'info',
805
+ event: 'admin_run_action',
806
+ ...input,
807
+ ...(input.sessionId ? {} : { sessionId: undefined }),
808
+ ...(input.agentId ? {} : { agentId: undefined }),
809
+ ...(input.keyId ? {} : { keyId: undefined })
810
+ }));
811
+ }
812
+ async function getRunForDataPlane(sessions, runId, ownerKeyId) {
813
+ const target = sessions;
814
+ for (const name of ['getRunForOwner', 'getRunOwnedBy']) {
815
+ if (typeof target[name] !== 'function')
816
+ continue;
817
+ const result = await target[name].call(sessions, runId, ownerKeyId);
818
+ if (!result)
819
+ throw new RequestError(404, 'Run not found');
820
+ return result;
821
+ }
822
+ if (typeof target.ownsRun === 'function') {
823
+ const owned = await target.ownsRun.call(sessions, runId, ownerKeyId);
824
+ if (!owned)
825
+ throw new RequestError(404, 'Run not found');
826
+ }
827
+ const run = sessions.getRun(runId);
828
+ if (typeof target.ownsRun !== 'function') {
829
+ // Before the worker adds a direct RunStore wrapper, use the original
830
+ // Session ownership check. It deliberately fails closed for persisted
831
+ // runs whose in-memory Session has already been evicted.
832
+ try {
833
+ if (!sessions.owns(run.sessionId, ownerKeyId))
834
+ throw new Error();
835
+ }
836
+ catch {
837
+ throw new RequestError(404, 'Run not found');
838
+ }
839
+ }
840
+ return run;
841
+ }
842
+ async function writeRunArtifact(response, sessions, run, artifactId) {
843
+ const artifact = Array.isArray(run.artifacts)
844
+ ? run.artifacts.find((value) => value?.id === artifactId)
845
+ : undefined;
846
+ if (!artifact)
847
+ throw new RequestError(404, 'Artifact not found');
848
+ const payload = await resolveArtifactPayload(sessions, run.id, artifactId);
849
+ if (!payload)
850
+ throw new RequestError(404, 'Artifact bytes are unavailable');
851
+ const filename = safeDownloadFilename(payload.filename || artifact.filename || artifact.name || artifactId);
852
+ const mediaType = cleanMediaType(payload.mediaType || artifact.mediaType);
853
+ response.writeHead(200, {
854
+ 'Content-Type': mediaType,
855
+ 'Content-Length': payload.bytes.length,
856
+ 'Content-Disposition': contentDisposition(filename),
857
+ 'Cache-Control': 'no-store',
858
+ 'X-Content-Type-Options': 'nosniff'
859
+ });
860
+ response.end(payload.bytes);
861
+ }
862
+ async function resolveArtifactPayload(sessions, runId, artifactId) {
863
+ return sessions.readRunArtifact(runId, artifactId);
864
+ }
865
+ function safeDownloadFilename(value) {
866
+ const text = String(value || 'artifact')
867
+ .replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, '_')
868
+ .replace(/\s+/g, ' ')
869
+ .trim()
870
+ .slice(0, 180);
871
+ return text || 'artifact';
872
+ }
873
+ function contentDisposition(filename) {
874
+ const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_') || 'artifact';
875
+ return `attachment; filename="${fallback.replace(/"/g, '_')}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
876
+ }
877
+ function cleanMediaType(value) {
878
+ const text = typeof value === 'string' ? value.split(';', 1)[0].trim().toLowerCase() : '';
879
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(text)
880
+ ? text
881
+ : 'application/octet-stream';
882
+ }
883
+ function currentSseCount(context) {
884
+ return context.currentSse();
885
+ }
886
+ function reserveRunIfNeeded(context, keyId, sessionId, state) {
887
+ if (isTerminalState(state)) {
888
+ releaseRunReservation(context, sessionId);
889
+ return undefined;
890
+ }
891
+ if (context.runReservations.has(sessionId))
892
+ return undefined;
893
+ const releaseQuota = context.quota.reserveRunningRun(keyId, sessionId);
894
+ let released = false;
895
+ const release = () => {
896
+ if (released)
897
+ return;
898
+ released = true;
899
+ if (context.runReservations.get(sessionId) === release)
900
+ context.runReservations.delete(sessionId);
901
+ releaseQuota();
902
+ };
903
+ context.runReservations.set(sessionId, release);
904
+ return release;
905
+ }
906
+ function reconcileRunReservation(context, keyId, sessionId, state) {
907
+ if (isTerminalState(state)) {
908
+ releaseRunReservation(context, sessionId);
909
+ context.quota.finishRun(sessionId, keyId);
910
+ }
911
+ }
912
+ function releaseRunReservation(context, sessionId) {
913
+ const release = context.runReservations.get(sessionId);
914
+ if (release)
915
+ release();
916
+ else
917
+ context.quota.finishRun(sessionId);
918
+ }
919
+ function releaseSessionReservations(context, sessionId) {
920
+ const releaseSession = context.sessionReservations.get(sessionId);
921
+ if (releaseSession) {
922
+ context.sessionReservations.delete(sessionId);
923
+ releaseSession();
924
+ }
925
+ const releaseUpload = context.uploadReservations.get(sessionId);
926
+ if (releaseUpload) {
927
+ context.uploadReservations.delete(sessionId);
928
+ releaseUpload();
929
+ }
930
+ }
931
+ function reserveAdminRetry(context, keyId) {
932
+ const releaseSessionQuota = context.quota.reserveSession(keyId);
933
+ let releaseRunQuota;
934
+ try {
935
+ releaseRunQuota = context.quota.reserveRunningRun(keyId);
936
+ }
937
+ catch (error) {
938
+ releaseSessionQuota();
939
+ throw error;
940
+ }
941
+ let sessionId;
942
+ let sessionReleased = false;
943
+ let runReleased = false;
944
+ const releaseSession = () => {
945
+ if (sessionReleased)
946
+ return;
947
+ sessionReleased = true;
948
+ if (sessionId && context.sessionReservations.get(sessionId) === releaseSession) {
949
+ context.sessionReservations.delete(sessionId);
950
+ }
951
+ releaseSessionQuota();
952
+ };
953
+ const releaseRun = () => {
954
+ if (runReleased)
955
+ return;
956
+ runReleased = true;
957
+ if (sessionId && context.runReservations.get(sessionId) === releaseRun) {
958
+ context.runReservations.delete(sessionId);
959
+ }
960
+ releaseRunQuota?.();
961
+ };
962
+ return {
963
+ bind(id) {
964
+ if (sessionId)
965
+ return;
966
+ sessionId = id;
967
+ context.sessionReservations.set(id, releaseSession);
968
+ context.runReservations.set(id, releaseRun);
969
+ },
970
+ release() {
971
+ releaseSession();
972
+ releaseRun();
973
+ }
974
+ };
975
+ }
976
+ function composeReleases(left, right) {
977
+ let released = false;
978
+ return () => {
979
+ if (released)
980
+ return;
981
+ released = true;
982
+ left();
983
+ right();
984
+ };
985
+ }
986
+ function isActiveState(state) {
987
+ return state === 'running' || state === 'input_required' || state === 'permission_required';
988
+ }
989
+ function isTerminalState(state) {
990
+ return state === 'completed' || state === 'failed' || state === 'canceled';
991
+ }
992
+ function isRecord(value) {
993
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
994
+ }
392
995
  function readAgentUpdate(body) {
393
996
  assertOnlyKeys(body, [
394
997
  'protocol',
@@ -405,7 +1008,9 @@ function readAgentUpdate(body) {
405
1008
  'authType',
406
1009
  'authValue',
407
1010
  'authHeaderName',
408
- 'timeoutMs'
1011
+ 'timeoutMs',
1012
+ 'taskTimeoutMs',
1013
+ 'streamIdleTimeoutMs'
409
1014
  ]);
410
1015
  return {
411
1016
  protocol: requiredString(body.protocol, 'protocol'),
@@ -422,7 +1027,9 @@ function readAgentUpdate(body) {
422
1027
  authType: optionalString(body.authType),
423
1028
  authValue: optionalRawString(body.authValue, 'authValue'),
424
1029
  authHeaderName: optionalString(body.authHeaderName),
425
- timeoutMs: optionalNumber(body.timeoutMs, 'timeoutMs')
1030
+ timeoutMs: optionalNumber(body.timeoutMs, 'timeoutMs'),
1031
+ taskTimeoutMs: body.taskTimeoutMs === null ? null : optionalNumber(body.taskTimeoutMs, 'taskTimeoutMs'),
1032
+ streamIdleTimeoutMs: body.streamIdleTimeoutMs === null ? null : optionalNumber(body.streamIdleTimeoutMs, 'streamIdleTimeoutMs')
426
1033
  };
427
1034
  }
428
1035
  function readRuntimeSettingsUpdate(body) {
@@ -454,384 +1061,12 @@ function readApiKeyScope(value) {
454
1061
  agentIds: requiredStringArray(scope.agentIds ?? [], 'scope.agentIds', true)
455
1062
  };
456
1063
  }
457
- function streamEvents(request, response, sessions, sessionId, after, release) {
458
- response.writeHead(200, {
459
- 'Content-Type': 'text/event-stream; charset=utf-8',
460
- 'Cache-Control': 'no-cache, no-transform',
461
- Connection: 'keep-alive',
462
- 'X-Accel-Buffering': 'no'
463
- });
464
- response.flushHeaders?.();
465
- for (const event of sessions.eventsAfter(sessionId, after))
466
- writeEvent(response, event);
467
- const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
468
- // Give idle SSE clients ~90 s to react before Node's default socket timeout
469
- // trips. Combined with the writableEnded check inside the heartbeat this
470
- // guarantees stuck keep-alive sessions eventually release their slot even
471
- // if the peer never sends a FIN.
472
- const idleMs = 90_000;
473
- request.socket.setTimeout?.(idleMs);
474
- let closed = false;
475
- const close = () => {
476
- if (closed)
477
- return;
478
- closed = true;
479
- clearInterval(heartbeat);
480
- unsubscribe();
481
- // Ensure both ends of the socket are actually torn down so the SSE
482
- // slot returns to the pool even when the peer stops reading without
483
- // closing the TCP connection cleanly.
484
- try {
485
- if (!response.writableEnded)
486
- response.end();
487
- }
488
- catch { }
489
- try {
490
- request.socket.destroy();
491
- }
492
- catch { }
493
- release();
494
- };
495
- const heartbeat = setInterval(() => {
496
- if (response.destroyed || response.writableEnded) {
497
- close();
498
- return;
499
- }
500
- try {
501
- response.write(': heartbeat\n\n');
502
- }
503
- catch {
504
- close();
505
- }
506
- }, 15_000);
507
- heartbeat.unref?.();
508
- request.once('close', close);
509
- response.once('close', close);
510
- request.socket.once('timeout', close);
511
- request.socket.once('error', close);
512
- }
513
- function writeEvent(response, event) {
514
- if (response.destroyed || response.writableEnded)
515
- return;
516
- response.write(`id: ${event.id}\n`);
517
- response.write(`event: ${event.type}\n`);
518
- response.write(`data: ${JSON.stringify(event)}\n\n`);
519
- }
520
- function authenticateAdmin(request, sessions) {
521
- const id = cookieValue(request, ADMIN_COOKIE);
522
- if (!id || !sessions.has(id))
523
- throw new RequestError(401, 'Admin session is required');
524
- }
525
- function authenticateApiKey(request, config, controlPlane, limiter) {
526
- const rateKey = remoteKey(request);
527
- limiter.assertAllowed(rateKey);
528
- const value = stringHeader(request.headers.authorization);
529
- if (!value.startsWith('Bearer ')) {
530
- limiter.failure(rateKey);
531
- throw new RequestError(401, 'Bearer API Key is required');
532
- }
533
- const secret = value.slice(7);
534
- const authenticate = controlPlane?.authenticateApiKey;
535
- const principal = (typeof authenticate === 'function'
536
- ? authenticate.call(controlPlane, secret)
537
- : undefined) || fallbackApiKey(config, secret);
538
- if (!principal) {
539
- limiter.failure(rateKey);
540
- throw new RequestError(401, 'Invalid API Key');
541
- }
542
- limiter.success(rateKey);
543
- return principal;
544
- }
545
- function fallbackApiKey(config, secret) {
546
- for (const key of config.apiKeys || []) {
547
- if (key.enabled && safeEqual(key.secret, secret)) {
548
- return { id: key.id, scope: structuredClone(key.scope) };
549
- }
550
- }
551
- if (config.authToken && safeEqual(config.authToken, secret)) {
552
- return { id: 'legacy', scope: { allAgents: true, agentIds: [] } };
553
- }
554
- return undefined;
555
- }
556
- function assertAgentScope(principal, agentId) {
557
- if (!principal.scope.allAgents && !principal.scope.agentIds.includes(agentId)) {
558
- throw new RequestError(403, 'API Key is not authorized for this agent');
559
- }
560
- }
561
- function isInitialized(config, controlPlane) {
562
- return controlPlane?.isInitialized() ?? config.initialized;
563
- }
564
- function needsAdminSetup(config, controlPlane) {
565
- const value = controlPlane?.needsAdminSetup;
566
- return typeof value === 'function' ? value.call(controlPlane) : !config.adminPasswordHash;
567
- }
568
- function assertTrustedOrigin(request, required) {
569
- const origin = stringHeader(request.headers.origin);
570
- if (!origin) {
571
- if (required)
572
- throw new RequestError(403, 'Same-origin request is required');
573
- return;
574
- }
575
- let originHost = '';
576
- try {
577
- originHost = new URL(origin).host;
578
- }
579
- catch {
580
- throw new RequestError(403, 'Request origin is invalid');
581
- }
582
- if (!originHost || originHost !== stringHeader(request.headers.host)) {
583
- throw new RequestError(403, 'Cross-origin admin request is not allowed');
584
- }
585
- }
586
- function assertJsonContentType(request) {
587
- const contentType = stringHeader(request.headers['content-type'])
588
- .split(';', 1)[0]
589
- .trim()
590
- .toLowerCase();
591
- if (contentType !== 'application/json') {
592
- throw new RequestError(415, 'Request requires application/json');
593
- }
594
- }
595
- async function assertEmptyJsonBody(request, maxBytes) {
596
- if (Number(request.headers['content-length'] || 0) <= 0)
597
- return;
598
- assertJsonContentType(request);
599
- const body = await readJsonBody(request, maxBytes);
600
- assertOnlyKeys(body, []);
601
- }
602
- async function readJsonBody(request, maxBytes) {
603
- const declared = Number(request.headers['content-length']);
604
- if (Number.isFinite(declared) && declared > maxBytes) {
605
- throw new RequestError(413, 'Request body is too large');
606
- }
607
- const chunks = [];
608
- let total = 0;
609
- for await (const value of request) {
610
- const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
611
- total += chunk.length;
612
- if (total > maxBytes)
613
- throw new RequestError(413, 'Request body is too large');
614
- chunks.push(chunk);
615
- }
616
- const raw = Buffer.concat(chunks).toString('utf8');
617
- if (!raw.trim())
618
- return {};
619
- try {
620
- const parsed = JSON.parse(raw);
621
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
622
- throw new Error('body must be an object');
623
- }
624
- return parsed;
625
- }
626
- catch (error) {
627
- throw new RequestError(400, `Invalid JSON body: ${errorMessage(error)}`);
628
- }
629
- }
630
- async function readBytesBody(request, maxBytes) {
631
- const declared = Number(request.headers['content-length']);
632
- if (Number.isFinite(declared) && declared > maxBytes) {
633
- throw new RequestError(413, 'Request body is too large');
634
- }
635
- const chunks = [];
636
- let total = 0;
637
- for await (const value of request) {
638
- const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
639
- total += chunk.length;
640
- if (total > maxBytes)
641
- throw new RequestError(413, 'Request body is too large');
642
- chunks.push(chunk);
643
- }
644
- return Buffer.concat(chunks);
645
- }
646
- function assertOnlyKeys(body, allowed) {
647
- const accepted = new Set(allowed);
648
- const unknown = Object.keys(body).filter((key) => !accepted.has(key));
649
- if (unknown.length) {
650
- throw new RequestError(400, `Unsupported request fields: ${unknown.join(', ')}`);
651
- }
652
- }
653
- function requiredString(value, name) {
654
- const text = typeof value === 'string' ? value.trim() : '';
655
- if (!text)
656
- throw new RequestError(400, `${name} is required`);
657
- return text;
658
- }
659
- function requiredRawString(value, name) {
660
- if (typeof value !== 'string' || !value)
661
- throw new RequestError(400, `${name} is required`);
662
- return value;
663
- }
664
- function requiredMessage(value) {
665
- if (typeof value !== 'string' || !value.trim()) {
666
- throw new RequestError(400, 'message is required');
667
- }
668
- return value;
669
- }
670
- function requiredInteger(value, name) {
671
- if (typeof value !== 'number' || !Number.isInteger(value)) {
672
- throw new RequestError(400, `${name} must be an integer`);
673
- }
674
- return value;
675
- }
676
- function optionalAttachmentIds(value) {
677
- if (value === undefined)
678
- return [];
679
- if (!Array.isArray(value) || value.length > 16) {
680
- throw new RequestError(400, 'attachments must be an array with at most 16 ids');
681
- }
682
- return value.map((item) => requiredString(item, 'attachments'));
683
- }
684
- function normalizePublishPaths(body) {
685
- const bodyPaths = body.paths;
686
- if (bodyPaths !== undefined) {
687
- if (!Array.isArray(bodyPaths) || bodyPaths.length === 0) {
688
- throw new RequestError(400, 'paths must be a non-empty array of strings');
689
- }
690
- if (bodyPaths.length > 32) {
691
- throw new RequestError(400, 'paths accepts at most 32 entries');
692
- }
693
- return bodyPaths.map((item) => requiredString(item, 'paths'));
694
- }
695
- return [requiredString(body.path, 'path')];
696
- }
697
- function normalizedMediaType(value) {
698
- const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
699
- return mediaType || undefined;
700
- }
701
- function decodeHeader(value) {
702
- const raw = stringHeader(value);
703
- if (!raw)
704
- return '';
705
- try {
706
- return decodeURIComponent(raw);
707
- }
708
- catch {
709
- return raw;
710
- }
711
- }
712
- function cleanQuery(value) {
713
- const text = String(value || '').trim();
714
- return text || undefined;
715
- }
716
- function boundedLimit(value) {
717
- if (!value)
718
- return undefined;
719
- const number = Number(value);
720
- if (!Number.isInteger(number) || number < 1 || number > 200) {
721
- throw new RequestError(400, 'limit must be an integer between 1 and 200');
722
- }
723
- return number;
724
- }
725
- function optionalRunState(value) {
726
- if (!value)
727
- return undefined;
728
- const states = new Set([
729
- 'created',
730
- 'running',
731
- 'input_required',
732
- 'permission_required',
733
- 'completed',
734
- 'failed',
735
- 'canceled'
736
- ]);
737
- if (!states.has(value))
738
- throw new RequestError(400, 'Invalid run state');
739
- return value;
740
- }
741
- function optionalRawString(value, name) {
742
- if (value === undefined || value === null || value === '')
743
- return undefined;
744
- if (typeof value !== 'string')
745
- throw new RequestError(400, `${name} must be a string`);
746
- return value;
747
- }
748
- function optionalString(value) {
749
- if (value === undefined || value === null)
750
- return undefined;
751
- if (typeof value !== 'string')
752
- throw new RequestError(400, 'Expected a string');
753
- return value.trim() || undefined;
754
- }
755
- function optionalPendingAction(value) {
756
- if (value === undefined || value === null || value === '')
757
- return undefined;
758
- if (value === 'accept' || value === 'decline' || value === 'cancel') {
759
- return value;
760
- }
761
- throw new RequestError(400, 'action must be accept, decline, or cancel');
762
- }
763
- function optionalBoolean(value, name) {
764
- if (value === undefined)
765
- return undefined;
766
- if (typeof value !== 'boolean')
767
- throw new RequestError(400, `${name} must be boolean`);
768
- return value;
769
- }
770
- function optionalNumber(value, name) {
771
- if (value === undefined)
772
- return undefined;
773
- if (typeof value !== 'number' || !Number.isInteger(value)) {
774
- throw new RequestError(400, `${name} must be an integer`);
775
- }
776
- return value;
777
- }
778
- function requiredStringArray(value, name, allowEmpty = false) {
779
- if (!Array.isArray(value))
780
- throw new RequestError(400, `${name} must be an array of strings`);
781
- const result = value.map((item) => {
782
- if (typeof item !== 'string' || !item.trim()) {
783
- throw new RequestError(400, `${name} must contain non-empty strings`);
784
- }
785
- return item.trim();
786
- });
787
- if (!allowEmpty && !result.length) {
788
- throw new RequestError(400, `${name} must contain at least one value`);
789
- }
790
- return result;
791
- }
792
- function cookieValue(request, name) {
793
- const cookie = stringHeader(request.headers.cookie);
794
- for (const part of cookie.split(';')) {
795
- const index = part.indexOf('=');
796
- if (index < 0 || part.slice(0, index).trim() !== name)
797
- continue;
798
- return part.slice(index + 1).trim();
799
- }
800
- return '';
801
- }
802
- function adminCookie(value, ttlMs, config) {
803
- return [
804
- `${ADMIN_COOKIE}=${value}`,
805
- 'Path=/',
806
- 'HttpOnly',
807
- 'SameSite=Strict',
808
- `Max-Age=${Math.floor(ttlMs / 1000)}`,
809
- ...(config.secureAdminCookies ? ['Secure'] : [])
810
- ].join('; ');
811
- }
812
- function clearAdminCookie(config) {
813
- return [
814
- `${ADMIN_COOKIE}=`,
815
- 'Path=/',
816
- 'HttpOnly',
817
- 'SameSite=Strict',
818
- 'Max-Age=0',
819
- ...(config.secureAdminCookies ? ['Secure'] : [])
820
- ].join('; ');
821
- }
822
- function isMutating(method) {
823
- return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
824
- }
825
- function remoteKey(request) {
826
- return request.socket.remoteAddress || 'unknown';
827
- }
828
- function stringHeader(value) {
829
- return Array.isArray(value) ? value[0] || '' : value || '';
830
- }
831
- function safeEqual(left, right) {
832
- const a = Buffer.from(left);
833
- const b = Buffer.from(right);
834
- return a.length === b.length && timingSafeEqual(a, b);
1064
+ function boundedOffset(value) {
1065
+ if (value === null)
1066
+ return 0;
1067
+ if (!/^\d{1,9}$/.test(value))
1068
+ throw new RequestError(400, 'offset must be a non-negative integer');
1069
+ return Number(value);
835
1070
  }
836
1071
  function safePath(value) {
837
1072
  try {
@@ -852,6 +1087,8 @@ function errorStatus(error) {
852
1087
  return error.status;
853
1088
  if (error instanceof RequestError)
854
1089
  return error.status;
1090
+ if (error instanceof QuotaExceededError || error instanceof DiagnosticConcurrencyError)
1091
+ return 429;
855
1092
  return 500;
856
1093
  }
857
1094
  function errorMessage(error) {
@@ -867,12 +1104,31 @@ function writeJson(response, status, value) {
867
1104
  });
868
1105
  response.end(body);
869
1106
  }
870
- class RequestError extends Error {
871
- status;
872
- constructor(status, message) {
873
- super(message);
874
- this.status = status;
1107
+ function writeError(response, status, error, requestId) {
1108
+ if (error instanceof QuotaExceededError) {
1109
+ writeJson(response, status, {
1110
+ error: 'Quota exceeded',
1111
+ quota: error.quota,
1112
+ limit: error.limit,
1113
+ current: error.current,
1114
+ retryAfterMs: error.retryAfterMs,
1115
+ requestId
1116
+ });
1117
+ return;
875
1118
  }
1119
+ if (error instanceof DiagnosticConcurrencyError) {
1120
+ writeJson(response, status, {
1121
+ error: 'Diagnostic capacity has been reached',
1122
+ quota: 'diagnostics',
1123
+ retryAfterMs: error.retryAfterMs,
1124
+ requestId
1125
+ });
1126
+ return;
1127
+ }
1128
+ writeJson(response, status, {
1129
+ error: status >= 500 ? 'Internal server error' : errorMessage(error),
1130
+ requestId
1131
+ });
876
1132
  }
877
1133
  class FailureRateLimiter {
878
1134
  maximum;
@@ -892,6 +1148,14 @@ class FailureRateLimiter {
892
1148
  }
893
1149
  failure(key) {
894
1150
  const now = Date.now();
1151
+ // Bound untrusted remote-address cardinality and evict expired failures.
1152
+ for (const [id, item] of this.entries) {
1153
+ if (item.blockedUntil <= now && now - item.windowStartedAt >= this.windowMs)
1154
+ this.entries.delete(id);
1155
+ }
1156
+ if (!this.entries.has(key) && this.entries.size >= 4096) {
1157
+ throw new RequestError(429, 'Too many authentication attempts');
1158
+ }
895
1159
  const previous = this.entries.get(key);
896
1160
  const entry = !previous || now - previous.windowStartedAt >= this.windowMs
897
1161
  ? { count: 0, windowStartedAt: now, blockedUntil: 0 }