nexus-agentd 0.2.5 → 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.
- package/README.md +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
package/dist/server.js
CHANGED
|
@@ -1,18 +1,73 @@
|
|
|
1
|
-
import { randomUUID
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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', () =>
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 }
|