nexus-agentd 0.2.6 → 0.3.1

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/README.md +103 -14
  2. package/dist/a2a/runtime.d.ts +4 -1
  3. package/dist/a2a/runtime.js +213 -10
  4. package/dist/acp/runtime.js +13 -6
  5. package/dist/artifact-store.d.ts +168 -0
  6. package/dist/artifact-store.js +1111 -0
  7. package/dist/cli.js +13 -14
  8. package/dist/config.js +45 -5
  9. package/dist/control-plane-validation.d.ts +54 -0
  10. package/dist/control-plane-validation.js +152 -0
  11. package/dist/control-plane.d.ts +9 -32
  12. package/dist/control-plane.js +41 -157
  13. package/dist/drivers/index.js +12 -0
  14. package/dist/drivers/pi.js +1 -1
  15. package/dist/drivers/stdio.d.ts +4 -1
  16. package/dist/drivers/stdio.js +135 -19
  17. package/dist/events.d.ts +9 -0
  18. package/dist/events.js +17 -4
  19. package/dist/index.js +10 -1
  20. package/dist/process-tree.js +15 -6
  21. package/dist/run-store.d.ts +90 -7
  22. package/dist/run-store.js +254 -22
  23. package/dist/server/auth-policy.d.ts +27 -0
  24. package/dist/server/auth-policy.js +143 -0
  25. package/dist/server/connection.d.ts +8 -0
  26. package/dist/server/connection.js +67 -0
  27. package/dist/server/diagnostics.d.ts +40 -0
  28. package/dist/server/diagnostics.js +130 -0
  29. package/dist/server/metrics.d.ts +51 -0
  30. package/dist/server/metrics.js +56 -0
  31. package/dist/server/quota.d.ts +59 -0
  32. package/dist/server/quota.js +176 -0
  33. package/dist/server/validation.d.ts +29 -0
  34. package/dist/server/validation.js +221 -0
  35. package/dist/server.d.ts +3 -0
  36. package/dist/server.js +737 -419
  37. package/dist/session/admin-runs.d.ts +73 -0
  38. package/dist/session/admin-runs.js +298 -0
  39. package/dist/session/admission.d.ts +32 -0
  40. package/dist/session/admission.js +62 -0
  41. package/dist/session/readiness.d.ts +33 -0
  42. package/dist/session/readiness.js +174 -0
  43. package/dist/session.d.ts +65 -7
  44. package/dist/session.js +256 -109
  45. package/dist/sse-writer.d.ts +7 -0
  46. package/dist/sse-writer.js +60 -0
  47. package/dist/types.d.ts +46 -2
  48. package/dist/types.js +1 -0
  49. package/dist/webui/app/sources.js +18 -9
  50. package/dist/webui/icons.d.ts +1 -0
  51. package/dist/webui/icons.js +1 -0
  52. package/dist/webui/markup.js +3 -0
  53. package/dist/webui/styles.js +105 -15
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +19 -0
  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,93 @@ 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')),
499
+ limit: boundedLimit(url.searchParams.get('limit'))
500
+ }));
501
+ return;
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
+ if (url.pathname === '/v1/admin/artifacts' && request.method === 'GET') {
509
+ writeJson(response, 200, sessions.listArtifacts({
510
+ agentId: cleanQuery(url.searchParams.get('agentId')),
511
+ kind: cleanQuery(url.searchParams.get('kind')),
512
+ query: cleanQuery(url.searchParams.get('q')),
513
+ offset: boundedOffset(url.searchParams.get('offset')),
302
514
  limit: boundedLimit(url.searchParams.get('limit'))
303
515
  }));
304
516
  return;
305
517
  }
518
+ const adminArtifactMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)\/artifacts\/([^/]+)$/);
519
+ if (adminArtifactMatch && request.method === 'GET') {
520
+ const run = sessions.getRun(decodeURIComponent(adminArtifactMatch[1]));
521
+ await writeRunArtifact(response, sessions, run, decodeURIComponent(adminArtifactMatch[2]));
522
+ return;
523
+ }
524
+ if (adminArtifactMatch && request.method === 'DELETE') {
525
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
526
+ const runId = decodeURIComponent(adminArtifactMatch[1]);
527
+ const artifactId = decodeURIComponent(adminArtifactMatch[2]);
528
+ const run = sessions.getRun(runId);
529
+ if (!isTerminalState(run.state)) {
530
+ throw new RequestError(409, 'Artifacts of active runs cannot be deleted; cancel the task first');
531
+ }
532
+ const deleted = await sessions.removeRunArtifact(runId, artifactId);
533
+ if (!deleted)
534
+ throw new RequestError(404, 'Agent Nexus artifact not found');
535
+ console.info(JSON.stringify({
536
+ level: 'info',
537
+ event: 'admin_artifact_deleted',
538
+ runId,
539
+ artifactId,
540
+ agentId: run.agentId,
541
+ requestId: String(response.getHeader('X-Request-Id') || '')
542
+ }));
543
+ writeJson(response, 200, { deleted: true, runId, artifactId });
544
+ return;
545
+ }
306
546
  const runMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)$/);
307
- if (runMatch && request.method === 'GET') {
308
- writeJson(response, 200, sessions.getRun(decodeURIComponent(runMatch[1])));
547
+ if (runMatch && (request.method === 'GET' || request.method === 'DELETE')) {
548
+ const runId = decodeURIComponent(runMatch[1]);
549
+ if (request.method === 'DELETE') {
550
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
551
+ const detail = sessions.getRun(runId);
552
+ if (!isTerminalState(detail.state)) {
553
+ throw new RequestError(409, 'Active runs cannot be deleted; cancel the task first');
554
+ }
555
+ const deleted = sessions.deleteRun(runId);
556
+ if (!deleted)
557
+ throw new RequestError(404, 'Agent Nexus run not found');
558
+ console.info(JSON.stringify({
559
+ level: 'info',
560
+ event: 'admin_run_record_deleted',
561
+ runId,
562
+ sessionId: detail.sessionId,
563
+ agentId: detail.agentId,
564
+ state: detail.state,
565
+ requestId: String(response.getHeader('X-Request-Id') || '')
566
+ }));
567
+ writeJson(response, 200, { deleted: true, id: runId });
568
+ return;
569
+ }
570
+ const detail = sessions.getRun(runId);
571
+ writeJson(response, 200, {
572
+ ...detail,
573
+ controls: buildRunControls(sessions, detail)
574
+ });
575
+ return;
576
+ }
577
+ const diagnosticMatch = url.pathname.match(/^\/v1\/admin\/agents\/([^/]+)\/diagnostics$/);
578
+ if (diagnosticMatch && request.method === 'POST') {
579
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
580
+ const agentId = decodeURIComponent(diagnosticMatch[1]);
581
+ const configured = controlPlane.snapshot().agents.find((agent) => agent.id === agentId);
582
+ const workspace = configured?.workspace;
583
+ const result = await diagnostics.run(sessions, agentId, workspace, configured);
584
+ writeJson(response, result.status === 'busy' ? 429 : 200, result);
309
585
  return;
310
586
  }
311
587
  if (url.pathname === '/v1/admin/config/workspace-roots' && request.method === 'PUT') {
@@ -351,7 +627,12 @@ async function handleAdminRoute(context, url) {
351
627
  const action = keyMatch[2];
352
628
  if (action === 'reveal' && request.method === 'POST') {
353
629
  await assertEmptyJsonBody(request, config.maxRequestBytes);
354
- writeJson(response, 200, controlPlane.revealApiKey(id));
630
+ const rateKey = remoteKey(request);
631
+ context.revealLimiter.assertAllowed(rateKey);
632
+ context.revealLimiter.failure(rateKey);
633
+ const result = controlPlane.revealApiKey(id);
634
+ console.info(JSON.stringify({ level: 'info', event: 'api_key_revealed', keyId: id, requestId: response.getHeader('X-Request-Id'), remoteAddress: rateKey }));
635
+ writeJson(response, 200, result);
355
636
  return;
356
637
  }
357
638
  if (action === 'regenerate' && request.method === 'POST') {
@@ -389,6 +670,382 @@ async function handleAdminRoute(context, url) {
389
670
  }
390
671
  throw new RequestError(404, 'Route not found');
391
672
  }
673
+ async function handleAdminRunAction(context, runId, action) {
674
+ const { config, sessions, request, response } = context;
675
+ const requestId = String(response.getHeader('X-Request-Id') || '');
676
+ let detail;
677
+ let sessionId = '';
678
+ let agentId = '';
679
+ let ownerKeyId;
680
+ try {
681
+ if (request.method !== 'POST')
682
+ throw new RequestError(405, 'Method not allowed');
683
+ detail = sessions.getRun(runId);
684
+ sessionId = detail.sessionId;
685
+ agentId = detail.agentId;
686
+ ownerKeyId = runOwnerKeyId(sessions, runId);
687
+ const controls = buildRunControls(sessions, detail);
688
+ if (action === 'cancel' && !controls.canCancel) {
689
+ throw new RequestError(409, controls.unavailableReason || 'Run cannot be canceled');
690
+ }
691
+ if (action === 'retry' && !controls.canRetry) {
692
+ throw new RequestError(409, controls.unavailableReason || 'Run cannot be retried');
693
+ }
694
+ if (action === 'cancel' || action === 'retry') {
695
+ await assertEmptyJsonBody(request, config.maxRequestBytes);
696
+ }
697
+ let input;
698
+ if (action === 'respond')
699
+ input = await readAdminRunResponse(request, config.maxRequestBytes);
700
+ // AdminRunManager calls this factory only after its synchronous retry
701
+ // deduplication gate accepts a genuinely new retry. This keeps a
702
+ // completed duplicate from consuming either session or run quota.
703
+ const reserveRetry = action === 'retry'
704
+ ? (keyId) => reserveAdminRetry(context, keyId)
705
+ : undefined;
706
+ const result = await invokeRunAction(sessions, action, runId, sessionId, input, reserveRetry);
707
+ const resultRunId = action === 'retry'
708
+ ? actionResultString(result, 'runId') || actionResultRecord(result, 'run')?.id || runId
709
+ : runId;
710
+ const resultSessionId = action === 'retry'
711
+ ? actionResultString(result, 'sessionId') || actionResultRecord(result, 'session')?.id || ''
712
+ : sessionId;
713
+ if (!resultSessionId && action === 'retry') {
714
+ throw new RequestError(502, 'Retry did not return a session');
715
+ }
716
+ const state = actionResultString(result, 'state') ||
717
+ actionResultRecord(result, 'session')?.state ||
718
+ actionResultRecord(result, 'run')?.state;
719
+ if (action === 'cancel' || state === 'canceled')
720
+ releaseRunReservation(context, sessionId);
721
+ if (action === 'retry' && state && isTerminalState(state) && resultSessionId) {
722
+ releaseRunReservation(context, resultSessionId);
723
+ }
724
+ auditAdminRunAction({
725
+ action,
726
+ runId,
727
+ sessionId,
728
+ agentId,
729
+ keyId: ownerKeyId,
730
+ requestId,
731
+ result: 'accepted'
732
+ });
733
+ if (action === 'retry') {
734
+ writeJson(response, 202, { runId: resultRunId, sessionId: resultSessionId });
735
+ }
736
+ else {
737
+ writeJson(response, 202, {
738
+ runId,
739
+ sessionId,
740
+ ...(state ? { state } : {}),
741
+ result: 'accepted'
742
+ });
743
+ }
744
+ }
745
+ catch (error) {
746
+ auditAdminRunAction({
747
+ action,
748
+ runId,
749
+ sessionId,
750
+ agentId,
751
+ keyId: ownerKeyId,
752
+ requestId,
753
+ result: 'failed'
754
+ });
755
+ throw error;
756
+ }
757
+ }
758
+ async function readAdminRunResponse(request, maxBytes) {
759
+ assertJsonContentType(request);
760
+ const body = await readJsonBody(request, maxBytes);
761
+ assertOnlyKeys(body, ['requestId', 'message', 'optionId', 'action']);
762
+ const result = {
763
+ requestId: requiredString(body.requestId, 'requestId'),
764
+ message: optionalRawString(body.message, 'message'),
765
+ optionId: optionalRawString(body.optionId, 'optionId'),
766
+ action: optionalPendingAction(body.action)
767
+ };
768
+ if (!result.message?.trim() && !result.optionId && !result.action) {
769
+ throw new RequestError(400, 'A pending response is required');
770
+ }
771
+ return result;
772
+ }
773
+ async function invokeRunAction(sessions, action, runId, sessionId, input, reserveRetry) {
774
+ const target = sessions;
775
+ const candidates = action === 'cancel'
776
+ ? ['cancelRun', 'cancelByRunId']
777
+ : action === 'retry'
778
+ ? ['retryRun', 'retryByRunId']
779
+ : ['respondRun', 'respondByRunId'];
780
+ for (const name of candidates) {
781
+ if (typeof target[name] !== 'function')
782
+ continue;
783
+ if (action === 'respond')
784
+ return target[name].call(sessions, runId, input);
785
+ if (action === 'retry')
786
+ return target[name].call(sessions, runId, reserveRetry);
787
+ return target[name].call(sessions, runId);
788
+ }
789
+ // Cancellation and pending responses can be safely delegated through the
790
+ // existing SessionManager methods. Retry intentionally has no fallback:
791
+ // reconstructing a prompt here could duplicate a run or lose attachments.
792
+ if (action === 'cancel' && typeof target.cancel === 'function') {
793
+ return target.cancel.call(sessions, sessionId);
794
+ }
795
+ if (action === 'respond' && typeof target.resolvePending === 'function') {
796
+ return target.resolvePending.call(sessions, sessionId, input);
797
+ }
798
+ throw new RequestError(409, `Run ${action} is unavailable`);
799
+ }
800
+ function buildRunControls(sessions, detail) {
801
+ const target = sessions;
802
+ if (typeof target.getRunControls === 'function') {
803
+ try {
804
+ return structuredClone(target.getRunControls(detail.id));
805
+ }
806
+ catch {
807
+ // Fall through to the compatibility calculation for embedders
808
+ // that expose getRunControls only for newer run records.
809
+ }
810
+ }
811
+ let session;
812
+ try {
813
+ session = sessions.get(detail.sessionId);
814
+ }
815
+ catch {
816
+ session = undefined;
817
+ }
818
+ const active = isActiveState(detail.state);
819
+ const canCancel = active && (typeof target.cancelRun === 'function' ||
820
+ typeof target.cancelByRunId === 'function' ||
821
+ typeof target.cancel === 'function');
822
+ const canRetry = (detail.state === 'failed' || detail.state === 'canceled') && (typeof target.retryRun === 'function' || typeof target.retryByRunId === 'function');
823
+ const pendingRequest = session?.pendingRequest || detail.pendingRequest;
824
+ const unavailableReason = !session && active
825
+ ? 'Session is no longer available'
826
+ : active && !canCancel
827
+ ? 'Cancellation is unavailable'
828
+ : (detail.state === 'failed' || detail.state === 'canceled') && !canRetry
829
+ ? 'Retry is unavailable'
830
+ : undefined;
831
+ return {
832
+ canCancel,
833
+ canRetry,
834
+ canDelete: !active,
835
+ ...(pendingRequest ? { pendingRequest: structuredClone(pendingRequest) } : {}),
836
+ ...(unavailableReason ? { unavailableReason } : {})
837
+ };
838
+ }
839
+ function runOwnerKeyId(sessions, runId) {
840
+ const target = sessions;
841
+ for (const name of ['getRunOwnerKeyId', 'runOwnerKeyId', 'ownerKeyForRun']) {
842
+ if (typeof target[name] !== 'function')
843
+ continue;
844
+ const value = target[name].call(sessions, runId);
845
+ if (typeof value === 'string' && value)
846
+ return value;
847
+ }
848
+ return undefined;
849
+ }
850
+ function actionResultString(value, key) {
851
+ return isRecord(value) && typeof value[key] === 'string' ? value[key] : undefined;
852
+ }
853
+ function actionResultRecord(value, key) {
854
+ return isRecord(value) && isRecord(value[key]) ? value[key] : undefined;
855
+ }
856
+ function auditAdminRunAction(input) {
857
+ console.info(JSON.stringify({
858
+ level: 'info',
859
+ event: 'admin_run_action',
860
+ ...input,
861
+ ...(input.sessionId ? {} : { sessionId: undefined }),
862
+ ...(input.agentId ? {} : { agentId: undefined }),
863
+ ...(input.keyId ? {} : { keyId: undefined })
864
+ }));
865
+ }
866
+ async function getRunForDataPlane(sessions, runId, ownerKeyId) {
867
+ const target = sessions;
868
+ for (const name of ['getRunForOwner', 'getRunOwnedBy']) {
869
+ if (typeof target[name] !== 'function')
870
+ continue;
871
+ const result = await target[name].call(sessions, runId, ownerKeyId);
872
+ if (!result)
873
+ throw new RequestError(404, 'Run not found');
874
+ return result;
875
+ }
876
+ if (typeof target.ownsRun === 'function') {
877
+ const owned = await target.ownsRun.call(sessions, runId, ownerKeyId);
878
+ if (!owned)
879
+ throw new RequestError(404, 'Run not found');
880
+ }
881
+ const run = sessions.getRun(runId);
882
+ if (typeof target.ownsRun !== 'function') {
883
+ // Before the worker adds a direct RunStore wrapper, use the original
884
+ // Session ownership check. It deliberately fails closed for persisted
885
+ // runs whose in-memory Session has already been evicted.
886
+ try {
887
+ if (!sessions.owns(run.sessionId, ownerKeyId))
888
+ throw new Error();
889
+ }
890
+ catch {
891
+ throw new RequestError(404, 'Run not found');
892
+ }
893
+ }
894
+ return run;
895
+ }
896
+ async function writeRunArtifact(response, sessions, run, artifactId) {
897
+ const artifact = Array.isArray(run.artifacts)
898
+ ? run.artifacts.find((value) => value?.id === artifactId)
899
+ : undefined;
900
+ if (!artifact)
901
+ throw new RequestError(404, 'Artifact not found');
902
+ const payload = await resolveArtifactPayload(sessions, run.id, artifactId);
903
+ if (!payload)
904
+ throw new RequestError(404, 'Artifact bytes are unavailable');
905
+ const filename = safeDownloadFilename(payload.filename || artifact.filename || artifact.name || artifactId);
906
+ const mediaType = cleanMediaType(payload.mediaType || artifact.mediaType);
907
+ response.writeHead(200, {
908
+ 'Content-Type': mediaType,
909
+ 'Content-Length': payload.bytes.length,
910
+ 'Content-Disposition': contentDisposition(filename),
911
+ 'Cache-Control': 'no-store',
912
+ 'X-Content-Type-Options': 'nosniff'
913
+ });
914
+ response.end(payload.bytes);
915
+ }
916
+ async function resolveArtifactPayload(sessions, runId, artifactId) {
917
+ return sessions.readRunArtifact(runId, artifactId);
918
+ }
919
+ function safeDownloadFilename(value) {
920
+ const text = String(value || 'artifact')
921
+ .replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, '_')
922
+ .replace(/\s+/g, ' ')
923
+ .trim()
924
+ .slice(0, 180);
925
+ return text || 'artifact';
926
+ }
927
+ function contentDisposition(filename) {
928
+ const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_') || 'artifact';
929
+ return `attachment; filename="${fallback.replace(/"/g, '_')}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
930
+ }
931
+ function cleanMediaType(value) {
932
+ const text = typeof value === 'string' ? value.split(';', 1)[0].trim().toLowerCase() : '';
933
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(text)
934
+ ? text
935
+ : 'application/octet-stream';
936
+ }
937
+ function currentSseCount(context) {
938
+ return context.currentSse();
939
+ }
940
+ function reserveRunIfNeeded(context, keyId, sessionId, state) {
941
+ if (isTerminalState(state)) {
942
+ releaseRunReservation(context, sessionId);
943
+ return undefined;
944
+ }
945
+ if (context.runReservations.has(sessionId))
946
+ return undefined;
947
+ const releaseQuota = context.quota.reserveRunningRun(keyId, sessionId);
948
+ let released = false;
949
+ const release = () => {
950
+ if (released)
951
+ return;
952
+ released = true;
953
+ if (context.runReservations.get(sessionId) === release)
954
+ context.runReservations.delete(sessionId);
955
+ releaseQuota();
956
+ };
957
+ context.runReservations.set(sessionId, release);
958
+ return release;
959
+ }
960
+ function reconcileRunReservation(context, keyId, sessionId, state) {
961
+ if (isTerminalState(state)) {
962
+ releaseRunReservation(context, sessionId);
963
+ context.quota.finishRun(sessionId, keyId);
964
+ }
965
+ }
966
+ function releaseRunReservation(context, sessionId) {
967
+ const release = context.runReservations.get(sessionId);
968
+ if (release)
969
+ release();
970
+ else
971
+ context.quota.finishRun(sessionId);
972
+ }
973
+ function releaseSessionReservations(context, sessionId) {
974
+ const releaseSession = context.sessionReservations.get(sessionId);
975
+ if (releaseSession) {
976
+ context.sessionReservations.delete(sessionId);
977
+ releaseSession();
978
+ }
979
+ const releaseUpload = context.uploadReservations.get(sessionId);
980
+ if (releaseUpload) {
981
+ context.uploadReservations.delete(sessionId);
982
+ releaseUpload();
983
+ }
984
+ }
985
+ function reserveAdminRetry(context, keyId) {
986
+ const releaseSessionQuota = context.quota.reserveSession(keyId);
987
+ let releaseRunQuota;
988
+ try {
989
+ releaseRunQuota = context.quota.reserveRunningRun(keyId);
990
+ }
991
+ catch (error) {
992
+ releaseSessionQuota();
993
+ throw error;
994
+ }
995
+ let sessionId;
996
+ let sessionReleased = false;
997
+ let runReleased = false;
998
+ const releaseSession = () => {
999
+ if (sessionReleased)
1000
+ return;
1001
+ sessionReleased = true;
1002
+ if (sessionId && context.sessionReservations.get(sessionId) === releaseSession) {
1003
+ context.sessionReservations.delete(sessionId);
1004
+ }
1005
+ releaseSessionQuota();
1006
+ };
1007
+ const releaseRun = () => {
1008
+ if (runReleased)
1009
+ return;
1010
+ runReleased = true;
1011
+ if (sessionId && context.runReservations.get(sessionId) === releaseRun) {
1012
+ context.runReservations.delete(sessionId);
1013
+ }
1014
+ releaseRunQuota?.();
1015
+ };
1016
+ return {
1017
+ bind(id) {
1018
+ if (sessionId)
1019
+ return;
1020
+ sessionId = id;
1021
+ context.sessionReservations.set(id, releaseSession);
1022
+ context.runReservations.set(id, releaseRun);
1023
+ },
1024
+ release() {
1025
+ releaseSession();
1026
+ releaseRun();
1027
+ }
1028
+ };
1029
+ }
1030
+ function composeReleases(left, right) {
1031
+ let released = false;
1032
+ return () => {
1033
+ if (released)
1034
+ return;
1035
+ released = true;
1036
+ left();
1037
+ right();
1038
+ };
1039
+ }
1040
+ function isActiveState(state) {
1041
+ return state === 'running' || state === 'input_required' || state === 'permission_required';
1042
+ }
1043
+ function isTerminalState(state) {
1044
+ return state === 'completed' || state === 'failed' || state === 'canceled';
1045
+ }
1046
+ function isRecord(value) {
1047
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
1048
+ }
392
1049
  function readAgentUpdate(body) {
393
1050
  assertOnlyKeys(body, [
394
1051
  'protocol',
@@ -405,7 +1062,9 @@ function readAgentUpdate(body) {
405
1062
  'authType',
406
1063
  'authValue',
407
1064
  'authHeaderName',
408
- 'timeoutMs'
1065
+ 'timeoutMs',
1066
+ 'taskTimeoutMs',
1067
+ 'streamIdleTimeoutMs'
409
1068
  ]);
410
1069
  return {
411
1070
  protocol: requiredString(body.protocol, 'protocol'),
@@ -422,7 +1081,9 @@ function readAgentUpdate(body) {
422
1081
  authType: optionalString(body.authType),
423
1082
  authValue: optionalRawString(body.authValue, 'authValue'),
424
1083
  authHeaderName: optionalString(body.authHeaderName),
425
- timeoutMs: optionalNumber(body.timeoutMs, 'timeoutMs')
1084
+ timeoutMs: optionalNumber(body.timeoutMs, 'timeoutMs'),
1085
+ taskTimeoutMs: body.taskTimeoutMs === null ? null : optionalNumber(body.taskTimeoutMs, 'taskTimeoutMs'),
1086
+ streamIdleTimeoutMs: body.streamIdleTimeoutMs === null ? null : optionalNumber(body.streamIdleTimeoutMs, 'streamIdleTimeoutMs')
426
1087
  };
427
1088
  }
428
1089
  function readRuntimeSettingsUpdate(body) {
@@ -454,384 +1115,12 @@ function readApiKeyScope(value) {
454
1115
  agentIds: requiredStringArray(scope.agentIds ?? [], 'scope.agentIds', true)
455
1116
  };
456
1117
  }
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);
1118
+ function boundedOffset(value) {
1119
+ if (value === null)
1120
+ return 0;
1121
+ if (!/^\d{1,9}$/.test(value))
1122
+ throw new RequestError(400, 'offset must be a non-negative integer');
1123
+ return Number(value);
835
1124
  }
836
1125
  function safePath(value) {
837
1126
  try {
@@ -852,6 +1141,8 @@ function errorStatus(error) {
852
1141
  return error.status;
853
1142
  if (error instanceof RequestError)
854
1143
  return error.status;
1144
+ if (error instanceof QuotaExceededError || error instanceof DiagnosticConcurrencyError)
1145
+ return 429;
855
1146
  return 500;
856
1147
  }
857
1148
  function errorMessage(error) {
@@ -867,12 +1158,31 @@ function writeJson(response, status, value) {
867
1158
  });
868
1159
  response.end(body);
869
1160
  }
870
- class RequestError extends Error {
871
- status;
872
- constructor(status, message) {
873
- super(message);
874
- this.status = status;
1161
+ function writeError(response, status, error, requestId) {
1162
+ if (error instanceof QuotaExceededError) {
1163
+ writeJson(response, status, {
1164
+ error: 'Quota exceeded',
1165
+ quota: error.quota,
1166
+ limit: error.limit,
1167
+ current: error.current,
1168
+ retryAfterMs: error.retryAfterMs,
1169
+ requestId
1170
+ });
1171
+ return;
1172
+ }
1173
+ if (error instanceof DiagnosticConcurrencyError) {
1174
+ writeJson(response, status, {
1175
+ error: 'Diagnostic capacity has been reached',
1176
+ quota: 'diagnostics',
1177
+ retryAfterMs: error.retryAfterMs,
1178
+ requestId
1179
+ });
1180
+ return;
875
1181
  }
1182
+ writeJson(response, status, {
1183
+ error: status >= 500 ? 'Internal server error' : errorMessage(error),
1184
+ requestId
1185
+ });
876
1186
  }
877
1187
  class FailureRateLimiter {
878
1188
  maximum;
@@ -892,6 +1202,14 @@ class FailureRateLimiter {
892
1202
  }
893
1203
  failure(key) {
894
1204
  const now = Date.now();
1205
+ // Bound untrusted remote-address cardinality and evict expired failures.
1206
+ for (const [id, item] of this.entries) {
1207
+ if (item.blockedUntil <= now && now - item.windowStartedAt >= this.windowMs)
1208
+ this.entries.delete(id);
1209
+ }
1210
+ if (!this.entries.has(key) && this.entries.size >= 4096) {
1211
+ throw new RequestError(429, 'Too many authentication attempts');
1212
+ }
895
1213
  const previous = this.entries.get(key);
896
1214
  const entry = !previous || now - previous.windowStartedAt >= this.windowMs
897
1215
  ? { count: 0, windowStartedAt: now, blockedUntil: 0 }