nexus-agentd 0.1.0 → 0.1.6
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/LICENSE +22 -0
- package/README.md +351 -179
- package/dist/a2a/runtime.d.ts +30 -0
- package/dist/a2a/runtime.js +374 -0
- package/dist/acp/runtime.d.ts +12 -3
- package/dist/acp/runtime.js +248 -17
- package/dist/artifact-store.d.ts +41 -0
- package/dist/artifact-store.js +340 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +77 -0
- package/dist/cli.js +58 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +281 -53
- package/dist/control-plane.d.ts +91 -0
- package/dist/control-plane.js +629 -0
- package/dist/drivers/hermes.d.ts +2 -0
- package/dist/drivers/hermes.js +10 -0
- package/dist/drivers/index.js +6 -0
- package/dist/events.js +13 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +15 -3
- package/dist/run-store.d.ts +49 -0
- package/dist/run-store.js +291 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.js +911 -41
- package/dist/session-contract.d.ts +16 -4
- package/dist/session.d.ts +74 -8
- package/dist/session.js +433 -38
- package/dist/types.d.ts +193 -6
- package/dist/types.js +2 -1
- package/dist/webui/app.d.ts +1 -0
- package/dist/webui/app.js +1072 -0
- package/dist/webui/icons.d.ts +9 -0
- package/dist/webui/icons.js +10 -0
- package/dist/webui/index.d.ts +3 -0
- package/dist/webui/index.js +32 -0
- package/dist/webui/markup.d.ts +1 -0
- package/dist/webui/markup.js +68 -0
- package/dist/webui/styles.d.ts +1 -0
- package/dist/webui/styles.js +269 -0
- package/dist/workspace-files.d.ts +48 -0
- package/dist/workspace-files.js +269 -0
- package/nexus-agentd.example.json +30 -27
- package/package.json +6 -6
package/dist/server.js
CHANGED
|
@@ -1,41 +1,229 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import http from 'node:http';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { ArtifactStoreError, contentDisposition, GatewayArtifactStore, mediaTypeForName } from './artifact-store.js';
|
|
6
|
+
import { AdminSessionStore } from './auth.js';
|
|
7
|
+
import { ControlPlaneError } from './control-plane.js';
|
|
8
|
+
import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
|
|
9
|
+
import { redirectToAgentdWebUi, writeAgentdWebUi } from './webui/index.js';
|
|
10
|
+
import { WorkspaceFileError, WorkspaceFiles } from './workspace-files.js';
|
|
11
|
+
const ADMIN_COOKIE = 'agent_nexus_admin';
|
|
12
|
+
export function createAgentdServer(config, sessions, controlPlane) {
|
|
13
|
+
const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
|
|
14
|
+
const loginLimiter = new FailureRateLimiter(8, 60_000, 5 * 60_000);
|
|
15
|
+
const apiLimiter = new FailureRateLimiter(30, 60_000, 60_000);
|
|
16
|
+
const artifactStore = new GatewayArtifactStore(config.artifactStoragePath || path.resolve('.nexus-agentd-artifacts'), config.artifactTtlMs || 24 * 60 * 60 * 1000, config.maxArtifactBytes || 512 * 1024 * 1024, config.maxArtifactStorageBytes || 4 * 1024 * 1024 * 1024, config.maxPublishedArtifacts || 4096, config.maxConcurrentArtifactPublishes || 4);
|
|
17
|
+
const workspaceFiles = new WorkspaceFiles(() => controlPlane?.snapshot().workspaceRoots || config.workspaceRoots, config.maxArtifactBytes || 512 * 1024 * 1024);
|
|
18
|
+
artifactStore.startCleanup(config.cleanupIntervalMs);
|
|
19
|
+
let sseConnections = 0;
|
|
20
|
+
const server = http.createServer((request, response) => {
|
|
21
|
+
const requestId = randomUUID();
|
|
22
|
+
response.setHeader('X-Request-Id', requestId);
|
|
23
|
+
void handleRequest({
|
|
24
|
+
config,
|
|
25
|
+
sessions,
|
|
26
|
+
controlPlane,
|
|
27
|
+
adminSessions,
|
|
28
|
+
loginLimiter,
|
|
29
|
+
apiLimiter,
|
|
30
|
+
artifactStore,
|
|
31
|
+
workspaceFiles,
|
|
32
|
+
request,
|
|
33
|
+
response,
|
|
34
|
+
acquireSse() {
|
|
35
|
+
if (sseConnections >= (config.maxSseConnections || 128))
|
|
36
|
+
return false;
|
|
37
|
+
sseConnections += 1;
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
releaseSse() {
|
|
41
|
+
sseConnections = Math.max(0, sseConnections - 1);
|
|
42
|
+
}
|
|
43
|
+
}).catch((error) => {
|
|
7
44
|
if (response.headersSent) {
|
|
8
45
|
response.destroy(error instanceof Error ? error : undefined);
|
|
9
46
|
return;
|
|
10
47
|
}
|
|
11
|
-
const status = error
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
:
|
|
48
|
+
const status = errorStatus(error);
|
|
49
|
+
if (status >= 500) {
|
|
50
|
+
console.error(JSON.stringify({
|
|
51
|
+
level: 'error',
|
|
52
|
+
event: 'http_request_failed',
|
|
53
|
+
requestId,
|
|
54
|
+
method: request.method,
|
|
55
|
+
path: safePath(request.url),
|
|
56
|
+
message: errorMessage(error),
|
|
57
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
16
60
|
writeJson(response, status, {
|
|
17
|
-
error:
|
|
61
|
+
error: status >= 500 ? 'Internal server error' : errorMessage(error),
|
|
62
|
+
requestId
|
|
18
63
|
});
|
|
19
64
|
});
|
|
20
65
|
});
|
|
66
|
+
server.requestTimeout = config.requestTimeoutMs || 30_000;
|
|
67
|
+
server.headersTimeout = Math.min(server.requestTimeout, 20_000);
|
|
68
|
+
server.keepAliveTimeout = 5_000;
|
|
69
|
+
server.maxConnections = config.maxConnections || 256;
|
|
70
|
+
server.once('close', () => {
|
|
71
|
+
adminSessions.clear();
|
|
72
|
+
artifactStore.stopCleanup();
|
|
73
|
+
});
|
|
74
|
+
return server;
|
|
21
75
|
}
|
|
22
|
-
async function handleRequest(
|
|
76
|
+
async function handleRequest(context) {
|
|
77
|
+
const { config, sessions, controlPlane, adminSessions, loginLimiter, apiLimiter, artifactStore, workspaceFiles, request, response } = context;
|
|
23
78
|
const url = new URL(request.url || '/', 'http://localhost');
|
|
79
|
+
if (url.pathname === '/' && request.method === 'GET') {
|
|
80
|
+
redirectToAgentdWebUi(response);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if ((url.pathname === '/ui' || url.pathname === '/ui/') && request.method === 'GET') {
|
|
84
|
+
writeAgentdWebUi(response);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const artifactMatch = url.pathname.match(/^\/v1\/artifacts\/([a-f0-9]{64})\/(\d+)\/([^/]+)$/);
|
|
88
|
+
if (artifactMatch && request.method === 'GET') {
|
|
89
|
+
const artifact = await artifactStore.open(artifactMatch[1], artifactMatch[2], decodePathSegment(artifactMatch[3]));
|
|
90
|
+
await streamDownload(response, artifact.handle, artifact.name, artifact.size, artifact.mediaType);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
24
93
|
if (url.pathname === '/health' && request.method === 'GET') {
|
|
25
|
-
writeJson(response, 200, {
|
|
94
|
+
writeJson(response, 200, {
|
|
95
|
+
ok: true,
|
|
96
|
+
initialized: isInitialized(config, controlPlane),
|
|
97
|
+
adminSetupRequired: needsAdminSetup(config, controlPlane)
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (url.pathname === '/v1/bootstrap/status' && request.method === 'GET') {
|
|
102
|
+
writeJson(response, 200, {
|
|
103
|
+
initialized: isInitialized(config, controlPlane),
|
|
104
|
+
adminSetupRequired: needsAdminSetup(config, controlPlane)
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (url.pathname === '/v1/bootstrap/initialize' && request.method === 'POST') {
|
|
109
|
+
if (!controlPlane)
|
|
110
|
+
throw new RequestError(404, 'Control plane is unavailable');
|
|
111
|
+
assertJsonContentType(request);
|
|
112
|
+
assertTrustedOrigin(request, true);
|
|
113
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
114
|
+
assertOnlyKeys(body, ['password', 'confirmPassword']);
|
|
115
|
+
writeJson(response, 201, await controlPlane.initializeAdminPassword(requiredRawString(body.password, 'password'), requiredRawString(body.confirmPassword, 'confirmPassword')));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (url.pathname === '/v1/admin/auth/status' && request.method === 'GET') {
|
|
119
|
+
const id = cookieValue(request, ADMIN_COOKIE);
|
|
120
|
+
writeJson(response, 200, {
|
|
121
|
+
authenticated: Boolean(id && adminSessions.has(id)),
|
|
122
|
+
adminSetupRequired: needsAdminSetup(config, controlPlane)
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname === '/v1/admin/auth/login' && request.method === 'POST') {
|
|
127
|
+
if (!controlPlane)
|
|
128
|
+
throw new RequestError(404, 'Control plane is unavailable');
|
|
129
|
+
if (needsAdminSetup(config, controlPlane)) {
|
|
130
|
+
throw new RequestError(428, 'Console setup is required');
|
|
131
|
+
}
|
|
132
|
+
assertJsonContentType(request);
|
|
133
|
+
assertTrustedOrigin(request, true);
|
|
134
|
+
const rateKey = remoteKey(request);
|
|
135
|
+
loginLimiter.assertAllowed(rateKey);
|
|
136
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
137
|
+
assertOnlyKeys(body, ['password']);
|
|
138
|
+
const password = requiredRawString(body.password, 'password');
|
|
139
|
+
if (!(await controlPlane.verifyAdminPassword(password))) {
|
|
140
|
+
loginLimiter.failure(rateKey);
|
|
141
|
+
throw new RequestError(401, 'Invalid Console Password');
|
|
142
|
+
}
|
|
143
|
+
loginLimiter.success(rateKey);
|
|
144
|
+
const sessionId = adminSessions.create();
|
|
145
|
+
response.setHeader('Set-Cookie', adminCookie(sessionId, config.adminSessionTtlMs || 12 * 60 * 60 * 1000, config));
|
|
146
|
+
writeJson(response, 200, { authenticated: true });
|
|
26
147
|
return;
|
|
27
148
|
}
|
|
28
|
-
|
|
149
|
+
if (url.pathname === '/v1/admin/auth/logout' && request.method === 'POST') {
|
|
150
|
+
assertTrustedOrigin(request, true);
|
|
151
|
+
const id = cookieValue(request, ADMIN_COOKIE);
|
|
152
|
+
if (id)
|
|
153
|
+
adminSessions.delete(id);
|
|
154
|
+
response.setHeader('Set-Cookie', clearAdminCookie(config));
|
|
155
|
+
writeJson(response, 200, { authenticated: false });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!isInitialized(config, controlPlane)) {
|
|
159
|
+
throw new RequestError(428, 'Gateway setup is required');
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname.startsWith('/v1/admin/')) {
|
|
162
|
+
if (!controlPlane)
|
|
163
|
+
throw new RequestError(404, 'Control plane is unavailable');
|
|
164
|
+
authenticateAdmin(request, adminSessions);
|
|
165
|
+
if (isMutating(request.method))
|
|
166
|
+
assertTrustedOrigin(request, true);
|
|
167
|
+
await handleAdminRoute(context, url);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const principal = authenticateApiKey(request, config, controlPlane, apiLimiter);
|
|
29
171
|
if (url.pathname === '/v1/agents' && request.method === 'GET') {
|
|
30
|
-
|
|
172
|
+
const ids = principal.scope.allAgents
|
|
173
|
+
? undefined
|
|
174
|
+
: new Set(principal.scope.agentIds);
|
|
175
|
+
writeJson(response, 200, {
|
|
176
|
+
agents: await sessions.listAgents(ids, url.searchParams.get('refresh') === '1')
|
|
177
|
+
});
|
|
31
178
|
return;
|
|
32
179
|
}
|
|
33
180
|
if (url.pathname === '/v1/sessions' && request.method === 'POST') {
|
|
181
|
+
assertJsonContentType(request);
|
|
34
182
|
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
35
183
|
assertOnlyKeys(body, ['agentId', 'workspace']);
|
|
36
184
|
const agentId = requiredString(body.agentId, 'agentId');
|
|
37
|
-
|
|
38
|
-
writeJson(response, 201, await sessions.create(agentId, workspace));
|
|
185
|
+
assertAgentScope(principal, agentId);
|
|
186
|
+
writeJson(response, 201, await materializeSessionArtifacts(await sessions.create(agentId, optionalString(body.workspace), principal.id), artifactStore));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const filesMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/files(?:\/(content|publish))?$/);
|
|
190
|
+
if (filesMatch) {
|
|
191
|
+
const sessionId = decodePathSegment(filesMatch[1]);
|
|
192
|
+
const session = ownedSession(sessions, sessionId, principal);
|
|
193
|
+
const root = await workspaceFiles.sessionRoot(session.workspace);
|
|
194
|
+
const action = filesMatch[2];
|
|
195
|
+
if (!action && request.method === 'GET') {
|
|
196
|
+
writeJson(response, 200, await workspaceFiles.list(root, url.searchParams.get('path') || ''));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (action === 'content' && request.method === 'GET') {
|
|
200
|
+
const file = await workspaceFiles.open(root, requiredQuery(url, 'path'));
|
|
201
|
+
await streamDownload(response, file.handle, file.name, file.size, mediaTypeForName(file.name));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (action === 'publish' && request.method === 'POST') {
|
|
205
|
+
assertJsonContentType(request);
|
|
206
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
207
|
+
assertOnlyKeys(body, ['paths']);
|
|
208
|
+
const paths = requiredStringArray(body.paths, 'paths');
|
|
209
|
+
if (paths.length > 32)
|
|
210
|
+
throw new RequestError(400, 'paths must contain at most 32 values');
|
|
211
|
+
const result = await publishWorkspaceFiles(workspaceFiles, artifactStore, root, paths);
|
|
212
|
+
writeJson(response, result.errors.length ? 207 : 201, result);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
throw new RequestError(405, 'Method not allowed');
|
|
216
|
+
}
|
|
217
|
+
const attachmentMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/attachments$/);
|
|
218
|
+
if (attachmentMatch && request.method === 'POST') {
|
|
219
|
+
const sessionId = decodeURIComponent(attachmentMatch[1]);
|
|
220
|
+
const session = sessions.get(sessionId);
|
|
221
|
+
if (!sessions.owns(sessionId, principal.id)) {
|
|
222
|
+
throw new RequestError(404, 'Session not found');
|
|
223
|
+
}
|
|
224
|
+
assertAgentScope(principal, session.agentId);
|
|
225
|
+
const bytes = await readBytesBody(request, config.maxAttachmentBytes || 32 * 1024 * 1024);
|
|
226
|
+
writeJson(response, 201, sessions.addInputAttachment(sessionId, decodeHeader(request.headers['x-nexus-file-name']) || 'attachment', normalizedMediaType(request.headers['content-type']), bytes));
|
|
39
227
|
return;
|
|
40
228
|
}
|
|
41
229
|
const match = url.pathname.match(/^\/v1\/sessions\/([^/]+)(?:\/(message|cancel|events))?$/);
|
|
@@ -43,33 +231,276 @@ async function handleRequest(config, sessions, request, response) {
|
|
|
43
231
|
throw new RequestError(404, 'Route not found');
|
|
44
232
|
const sessionId = decodeURIComponent(match[1]);
|
|
45
233
|
const action = match[2];
|
|
234
|
+
const session = sessions.get(sessionId);
|
|
235
|
+
if (!sessions.owns(sessionId, principal.id)) {
|
|
236
|
+
throw new RequestError(404, 'Session not found');
|
|
237
|
+
}
|
|
238
|
+
assertAgentScope(principal, session.agentId);
|
|
46
239
|
if (!action && request.method === 'GET') {
|
|
47
|
-
writeJson(response, 200,
|
|
240
|
+
writeJson(response, 200, await materializeSessionArtifacts(session, artifactStore));
|
|
48
241
|
return;
|
|
49
242
|
}
|
|
50
243
|
if (action === 'message' && request.method === 'POST') {
|
|
244
|
+
assertJsonContentType(request);
|
|
51
245
|
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
52
|
-
assertOnlyKeys(body, ['message']);
|
|
53
|
-
writeJson(response, 202, await sessions.message(sessionId,
|
|
246
|
+
assertOnlyKeys(body, ['message', 'attachments']);
|
|
247
|
+
writeJson(response, 202, await materializeSessionArtifacts(await sessions.message(sessionId, requiredMessage(body.message), optionalAttachmentIds(body.attachments)), artifactStore));
|
|
54
248
|
return;
|
|
55
249
|
}
|
|
56
250
|
if (action === 'cancel' && request.method === 'POST') {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
assertOnlyKeys(body, []);
|
|
60
|
-
}
|
|
61
|
-
writeJson(response, 200, await sessions.cancel(sessionId));
|
|
251
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
252
|
+
writeJson(response, 200, await materializeSessionArtifacts(await sessions.cancel(sessionId), artifactStore));
|
|
62
253
|
return;
|
|
63
254
|
}
|
|
64
255
|
if (action === 'events' && request.method === 'GET') {
|
|
65
|
-
|
|
66
|
-
|
|
256
|
+
if (!context.acquireSse()) {
|
|
257
|
+
throw new RequestError(429, 'SSE connection capacity has been reached');
|
|
258
|
+
}
|
|
259
|
+
streamEvents(request, response, sessions, sessionId, url.searchParams.get('after') || stringHeader(request.headers['last-event-id']), context.releaseSse);
|
|
67
260
|
return;
|
|
68
261
|
}
|
|
69
262
|
throw new RequestError(405, 'Method not allowed');
|
|
70
263
|
}
|
|
71
|
-
function
|
|
72
|
-
sessions
|
|
264
|
+
async function handleAdminRoute(context, url) {
|
|
265
|
+
const { config, sessions, controlPlane, adminSessions, artifactStore, workspaceFiles, request, response } = context;
|
|
266
|
+
if (!controlPlane)
|
|
267
|
+
throw new RequestError(404, 'Control plane is unavailable');
|
|
268
|
+
if (url.pathname === '/v1/admin/files/roots' && request.method === 'GET') {
|
|
269
|
+
writeJson(response, 200, { roots: await workspaceFiles.roots() });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (url.pathname === '/v1/admin/files' && request.method === 'GET') {
|
|
273
|
+
const root = await workspaceFiles.adminRoot(requiredQuery(url, 'root'));
|
|
274
|
+
writeJson(response, 200, await workspaceFiles.list(root, url.searchParams.get('path') || ''));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (url.pathname === '/v1/admin/files/content') {
|
|
278
|
+
const root = await workspaceFiles.adminRoot(requiredQuery(url, 'root'));
|
|
279
|
+
const filePath = requiredQuery(url, 'path');
|
|
280
|
+
if (request.method === 'GET') {
|
|
281
|
+
const file = await workspaceFiles.open(root, filePath);
|
|
282
|
+
await streamDownload(response, file.handle, file.name, file.size, mediaTypeForName(file.name));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (request.method === 'PUT') {
|
|
286
|
+
const declared = optionalContentLength(request);
|
|
287
|
+
writeJson(response, 201, await workspaceFiles.write(root, filePath, request, declared));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (request.method === 'DELETE') {
|
|
291
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
292
|
+
writeJson(response, 200, await workspaceFiles.remove(root, filePath));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
throw new RequestError(405, 'Method not allowed');
|
|
296
|
+
}
|
|
297
|
+
if (url.pathname === '/v1/admin/files/directory' && request.method === 'POST') {
|
|
298
|
+
assertJsonContentType(request);
|
|
299
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
300
|
+
assertOnlyKeys(body, ['root', 'path']);
|
|
301
|
+
const root = await workspaceFiles.adminRoot(requiredString(body.root, 'root'));
|
|
302
|
+
writeJson(response, 201, await workspaceFiles.createDirectory(root, requiredString(body.path, 'path')));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (url.pathname === '/v1/admin/files/move' && request.method === 'POST') {
|
|
306
|
+
assertJsonContentType(request);
|
|
307
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
308
|
+
assertOnlyKeys(body, ['root', 'path', 'destination']);
|
|
309
|
+
const root = await workspaceFiles.adminRoot(requiredString(body.root, 'root'));
|
|
310
|
+
writeJson(response, 200, await workspaceFiles.move(root, requiredString(body.path, 'path'), requiredString(body.destination, 'destination')));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (url.pathname === '/v1/admin/files/publish' && request.method === 'POST') {
|
|
314
|
+
assertJsonContentType(request);
|
|
315
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
316
|
+
assertOnlyKeys(body, ['root', 'paths']);
|
|
317
|
+
const root = await workspaceFiles.adminRoot(requiredString(body.root, 'root'));
|
|
318
|
+
const paths = requiredStringArray(body.paths, 'paths');
|
|
319
|
+
if (paths.length > 32)
|
|
320
|
+
throw new RequestError(400, 'paths must contain at most 32 values');
|
|
321
|
+
const result = await publishWorkspaceFiles(workspaceFiles, artifactStore, root, paths);
|
|
322
|
+
writeJson(response, result.errors.length ? 207 : 201, result);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (url.pathname === '/v1/admin/overview' && request.method === 'GET') {
|
|
326
|
+
const agents = await sessions.listAgents(undefined, url.searchParams.get('refresh') === '1');
|
|
327
|
+
writeJson(response, 200, { agents, sessions: sessions.count() });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (url.pathname === '/v1/admin/config' && request.method === 'GET') {
|
|
331
|
+
writeJson(response, 200, controlPlane.snapshot());
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (url.pathname === '/v1/admin/agents' && request.method === 'GET') {
|
|
335
|
+
writeJson(response, 200, {
|
|
336
|
+
agents: await sessions.listAgents(undefined, url.searchParams.get('refresh') === '1'),
|
|
337
|
+
config: controlPlane.snapshot().agents
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (url.pathname === '/v1/admin/runs' && request.method === 'GET') {
|
|
342
|
+
const state = optionalRunState(url.searchParams.get('state'));
|
|
343
|
+
writeJson(response, 200, sessions.listRuns({
|
|
344
|
+
agentId: cleanQuery(url.searchParams.get('agentId')),
|
|
345
|
+
sessionId: cleanQuery(url.searchParams.get('sessionId')),
|
|
346
|
+
state,
|
|
347
|
+
query: cleanQuery(url.searchParams.get('q')),
|
|
348
|
+
limit: boundedLimit(url.searchParams.get('limit'))
|
|
349
|
+
}));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const runMatch = url.pathname.match(/^\/v1\/admin\/runs\/([^/]+)$/);
|
|
353
|
+
if (runMatch && request.method === 'GET') {
|
|
354
|
+
writeJson(response, 200, sessions.getRun(decodeURIComponent(runMatch[1])));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (url.pathname === '/v1/admin/config/workspace-roots' && request.method === 'PUT') {
|
|
358
|
+
assertJsonContentType(request);
|
|
359
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
360
|
+
assertOnlyKeys(body, ['workspaceRoots']);
|
|
361
|
+
writeJson(response, 200, await controlPlane.putWorkspaceRoots(requiredStringArray(body.workspaceRoots, 'workspaceRoots')));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (url.pathname === '/v1/admin/config/runtime' && request.method === 'PUT') {
|
|
365
|
+
assertJsonContentType(request);
|
|
366
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
367
|
+
assertOnlyKeys(body, ['sessionTtlMs', 'promptTimeoutMs', 'cleanupIntervalMs']);
|
|
368
|
+
writeJson(response, 200, await controlPlane.putRuntimeSettings(readRuntimeSettingsUpdate(body)));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (url.pathname === '/v1/admin/password' && request.method === 'PUT') {
|
|
372
|
+
assertJsonContentType(request);
|
|
373
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
374
|
+
assertOnlyKeys(body, ['currentPassword', 'newPassword', 'confirmPassword']);
|
|
375
|
+
const result = await controlPlane.changeAdminPassword(requiredRawString(body.currentPassword, 'currentPassword'), requiredRawString(body.newPassword, 'newPassword'), requiredRawString(body.confirmPassword, 'confirmPassword'));
|
|
376
|
+
adminSessions.clear();
|
|
377
|
+
response.setHeader('Set-Cookie', clearAdminCookie(config));
|
|
378
|
+
writeJson(response, 200, result);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (url.pathname === '/v1/admin/api-keys') {
|
|
382
|
+
if (request.method === 'GET') {
|
|
383
|
+
writeJson(response, 200, { apiKeys: controlPlane.listApiKeys() });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (request.method === 'POST') {
|
|
387
|
+
assertJsonContentType(request);
|
|
388
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
389
|
+
assertOnlyKeys(body, ['name', 'scope', 'customSecret']);
|
|
390
|
+
writeJson(response, 201, await controlPlane.createApiKey(requiredString(body.name, 'name'), readApiKeyScope(body.scope), optionalRawString(body.customSecret, 'customSecret')));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const keyMatch = url.pathname.match(/^\/v1\/admin\/api-keys\/([^/]+)(?:\/(reveal|regenerate))?$/);
|
|
395
|
+
if (keyMatch) {
|
|
396
|
+
const id = keyMatch[1];
|
|
397
|
+
const action = keyMatch[2];
|
|
398
|
+
if (action === 'reveal' && request.method === 'POST') {
|
|
399
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
400
|
+
writeJson(response, 200, controlPlane.revealApiKey(id));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (action === 'regenerate' && request.method === 'POST') {
|
|
404
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
405
|
+
writeJson(response, 200, await controlPlane.regenerateApiKey(id));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!action && request.method === 'PATCH') {
|
|
409
|
+
assertJsonContentType(request);
|
|
410
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
411
|
+
writeJson(response, 200, await controlPlane.updateApiKey(id, readApiKeyUpdate(body)));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!action && request.method === 'DELETE') {
|
|
415
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
416
|
+
writeJson(response, 200, await controlPlane.deleteApiKey(id));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
throw new RequestError(405, 'Method not allowed');
|
|
420
|
+
}
|
|
421
|
+
const agentMatch = url.pathname.match(/^\/v1\/admin\/agents\/([^/]+)$/);
|
|
422
|
+
if (agentMatch) {
|
|
423
|
+
if (request.method === 'PUT') {
|
|
424
|
+
assertJsonContentType(request);
|
|
425
|
+
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
426
|
+
writeJson(response, 200, await controlPlane.putAgent(agentMatch[1], readAgentUpdate(body)));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (request.method === 'DELETE') {
|
|
430
|
+
await assertEmptyJsonBody(request, config.maxRequestBytes);
|
|
431
|
+
writeJson(response, 200, await controlPlane.deleteAgent(agentMatch[1]));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
throw new RequestError(405, 'Method not allowed');
|
|
435
|
+
}
|
|
436
|
+
throw new RequestError(404, 'Route not found');
|
|
437
|
+
}
|
|
438
|
+
function readAgentUpdate(body) {
|
|
439
|
+
assertOnlyKeys(body, [
|
|
440
|
+
'protocol',
|
|
441
|
+
'driver',
|
|
442
|
+
'name',
|
|
443
|
+
'description',
|
|
444
|
+
'enabled',
|
|
445
|
+
'workspace',
|
|
446
|
+
'permissionPolicy',
|
|
447
|
+
'permissionTimeoutMs',
|
|
448
|
+
'agentCardUrl',
|
|
449
|
+
'agentUrl',
|
|
450
|
+
'preferredTransport',
|
|
451
|
+
'authType',
|
|
452
|
+
'authValue',
|
|
453
|
+
'authHeaderName',
|
|
454
|
+
'timeoutMs'
|
|
455
|
+
]);
|
|
456
|
+
return {
|
|
457
|
+
protocol: requiredString(body.protocol, 'protocol'),
|
|
458
|
+
driver: optionalString(body.driver),
|
|
459
|
+
name: optionalString(body.name),
|
|
460
|
+
description: optionalString(body.description),
|
|
461
|
+
enabled: optionalBoolean(body.enabled, 'enabled'),
|
|
462
|
+
workspace: optionalString(body.workspace),
|
|
463
|
+
permissionPolicy: optionalString(body.permissionPolicy),
|
|
464
|
+
permissionTimeoutMs: optionalNumber(body.permissionTimeoutMs, 'permissionTimeoutMs'),
|
|
465
|
+
agentCardUrl: optionalString(body.agentCardUrl),
|
|
466
|
+
agentUrl: optionalString(body.agentUrl),
|
|
467
|
+
preferredTransport: optionalString(body.preferredTransport),
|
|
468
|
+
authType: optionalString(body.authType),
|
|
469
|
+
authValue: optionalRawString(body.authValue, 'authValue'),
|
|
470
|
+
authHeaderName: optionalString(body.authHeaderName),
|
|
471
|
+
timeoutMs: optionalNumber(body.timeoutMs, 'timeoutMs')
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function readRuntimeSettingsUpdate(body) {
|
|
475
|
+
return {
|
|
476
|
+
sessionTtlMs: requiredInteger(body.sessionTtlMs, 'sessionTtlMs'),
|
|
477
|
+
promptTimeoutMs: requiredInteger(body.promptTimeoutMs, 'promptTimeoutMs'),
|
|
478
|
+
cleanupIntervalMs: requiredInteger(body.cleanupIntervalMs, 'cleanupIntervalMs')
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function readApiKeyUpdate(body) {
|
|
482
|
+
assertOnlyKeys(body, ['name', 'enabled', 'scope']);
|
|
483
|
+
return {
|
|
484
|
+
name: optionalString(body.name),
|
|
485
|
+
enabled: optionalBoolean(body.enabled, 'enabled'),
|
|
486
|
+
scope: body.scope === undefined ? undefined : readApiKeyScope(body.scope)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function readApiKeyScope(value) {
|
|
490
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
491
|
+
throw new RequestError(400, 'scope must be an object');
|
|
492
|
+
}
|
|
493
|
+
const scope = value;
|
|
494
|
+
assertOnlyKeys(scope, ['allAgents', 'agentIds']);
|
|
495
|
+
if (typeof scope.allAgents !== 'boolean') {
|
|
496
|
+
throw new RequestError(400, 'scope.allAgents must be boolean');
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
allAgents: scope.allAgents,
|
|
500
|
+
agentIds: requiredStringArray(scope.agentIds ?? [], 'scope.agentIds', true)
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function streamEvents(request, response, sessions, sessionId, after, release) {
|
|
73
504
|
response.writeHead(200, {
|
|
74
505
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
75
506
|
'Cache-Control': 'no-cache, no-transform',
|
|
@@ -77,14 +508,22 @@ function streamEvents(request, response, sessions, sessionId, after) {
|
|
|
77
508
|
'X-Accel-Buffering': 'no'
|
|
78
509
|
});
|
|
79
510
|
response.flushHeaders?.();
|
|
80
|
-
for (const event of sessions.eventsAfter(sessionId, after))
|
|
511
|
+
for (const event of sessions.eventsAfter(sessionId, after))
|
|
81
512
|
writeEvent(response, event);
|
|
82
|
-
}
|
|
83
513
|
const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
|
|
84
|
-
const heartbeat = setInterval(() =>
|
|
514
|
+
const heartbeat = setInterval(() => {
|
|
515
|
+
if (!response.destroyed && !response.writableEnded)
|
|
516
|
+
response.write(': heartbeat\n\n');
|
|
517
|
+
}, 15_000);
|
|
518
|
+
heartbeat.unref?.();
|
|
519
|
+
let closed = false;
|
|
85
520
|
const close = () => {
|
|
521
|
+
if (closed)
|
|
522
|
+
return;
|
|
523
|
+
closed = true;
|
|
86
524
|
clearInterval(heartbeat);
|
|
87
525
|
unsubscribe();
|
|
526
|
+
release();
|
|
88
527
|
};
|
|
89
528
|
request.once('close', close);
|
|
90
529
|
response.once('close', close);
|
|
@@ -96,17 +535,87 @@ function writeEvent(response, event) {
|
|
|
96
535
|
response.write(`event: ${event.type}\n`);
|
|
97
536
|
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
98
537
|
}
|
|
99
|
-
function
|
|
538
|
+
function authenticateAdmin(request, sessions) {
|
|
539
|
+
const id = cookieValue(request, ADMIN_COOKIE);
|
|
540
|
+
if (!id || !sessions.has(id))
|
|
541
|
+
throw new RequestError(401, 'Admin session is required');
|
|
542
|
+
}
|
|
543
|
+
function authenticateApiKey(request, config, controlPlane, limiter) {
|
|
544
|
+
const rateKey = remoteKey(request);
|
|
545
|
+
limiter.assertAllowed(rateKey);
|
|
100
546
|
const value = stringHeader(request.headers.authorization);
|
|
101
547
|
if (!value.startsWith('Bearer ')) {
|
|
102
|
-
|
|
548
|
+
limiter.failure(rateKey);
|
|
549
|
+
throw new RequestError(401, 'Bearer API Key is required');
|
|
103
550
|
}
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
551
|
+
const secret = value.slice(7);
|
|
552
|
+
const authenticate = controlPlane?.authenticateApiKey;
|
|
553
|
+
const principal = (typeof authenticate === 'function'
|
|
554
|
+
? authenticate.call(controlPlane, secret)
|
|
555
|
+
: undefined) || fallbackApiKey(config, secret);
|
|
556
|
+
if (!principal) {
|
|
557
|
+
limiter.failure(rateKey);
|
|
558
|
+
throw new RequestError(401, 'Invalid API Key');
|
|
109
559
|
}
|
|
560
|
+
limiter.success(rateKey);
|
|
561
|
+
return principal;
|
|
562
|
+
}
|
|
563
|
+
function fallbackApiKey(config, secret) {
|
|
564
|
+
for (const key of config.apiKeys || []) {
|
|
565
|
+
if (key.enabled && safeEqual(key.secret, secret)) {
|
|
566
|
+
return { id: key.id, scope: structuredClone(key.scope) };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (config.authToken && safeEqual(config.authToken, secret)) {
|
|
570
|
+
return { id: 'legacy', scope: { allAgents: true, agentIds: [] } };
|
|
571
|
+
}
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
function assertAgentScope(principal, agentId) {
|
|
575
|
+
if (!principal.scope.allAgents && !principal.scope.agentIds.includes(agentId)) {
|
|
576
|
+
throw new RequestError(403, 'API Key is not authorized for this agent');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function isInitialized(config, controlPlane) {
|
|
580
|
+
return controlPlane?.isInitialized() ?? config.initialized;
|
|
581
|
+
}
|
|
582
|
+
function needsAdminSetup(config, controlPlane) {
|
|
583
|
+
const value = controlPlane?.needsAdminSetup;
|
|
584
|
+
return typeof value === 'function' ? value.call(controlPlane) : !config.adminPasswordHash;
|
|
585
|
+
}
|
|
586
|
+
function assertTrustedOrigin(request, required) {
|
|
587
|
+
const origin = stringHeader(request.headers.origin);
|
|
588
|
+
if (!origin) {
|
|
589
|
+
if (required)
|
|
590
|
+
throw new RequestError(403, 'Same-origin request is required');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
let originHost = '';
|
|
594
|
+
try {
|
|
595
|
+
originHost = new URL(origin).host;
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
throw new RequestError(403, 'Request origin is invalid');
|
|
599
|
+
}
|
|
600
|
+
if (!originHost || originHost !== stringHeader(request.headers.host)) {
|
|
601
|
+
throw new RequestError(403, 'Cross-origin admin request is not allowed');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function assertJsonContentType(request) {
|
|
605
|
+
const contentType = stringHeader(request.headers['content-type'])
|
|
606
|
+
.split(';', 1)[0]
|
|
607
|
+
.trim()
|
|
608
|
+
.toLowerCase();
|
|
609
|
+
if (contentType !== 'application/json') {
|
|
610
|
+
throw new RequestError(415, 'Request requires application/json');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function assertEmptyJsonBody(request, maxBytes) {
|
|
614
|
+
if (Number(request.headers['content-length'] || 0) <= 0)
|
|
615
|
+
return;
|
|
616
|
+
assertJsonContentType(request);
|
|
617
|
+
const body = await readJsonBody(request, maxBytes);
|
|
618
|
+
assertOnlyKeys(body, []);
|
|
110
619
|
}
|
|
111
620
|
async function readJsonBody(request, maxBytes) {
|
|
112
621
|
const declared = Number(request.headers['content-length']);
|
|
@@ -133,8 +642,24 @@ async function readJsonBody(request, maxBytes) {
|
|
|
133
642
|
return parsed;
|
|
134
643
|
}
|
|
135
644
|
catch (error) {
|
|
136
|
-
throw new RequestError(400, `Invalid JSON body: ${
|
|
645
|
+
throw new RequestError(400, `Invalid JSON body: ${errorMessage(error)}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function readBytesBody(request, maxBytes) {
|
|
649
|
+
const declared = Number(request.headers['content-length']);
|
|
650
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
651
|
+
throw new RequestError(413, 'Request body is too large');
|
|
652
|
+
}
|
|
653
|
+
const chunks = [];
|
|
654
|
+
let total = 0;
|
|
655
|
+
for await (const value of request) {
|
|
656
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
657
|
+
total += chunk.length;
|
|
658
|
+
if (total > maxBytes)
|
|
659
|
+
throw new RequestError(413, 'Request body is too large');
|
|
660
|
+
chunks.push(chunk);
|
|
137
661
|
}
|
|
662
|
+
return Buffer.concat(chunks);
|
|
138
663
|
}
|
|
139
664
|
function assertOnlyKeys(body, allowed) {
|
|
140
665
|
const accepted = new Set(allowed);
|
|
@@ -149,15 +674,329 @@ function requiredString(value, name) {
|
|
|
149
674
|
throw new RequestError(400, `${name} is required`);
|
|
150
675
|
return text;
|
|
151
676
|
}
|
|
677
|
+
function requiredRawString(value, name) {
|
|
678
|
+
if (typeof value !== 'string' || !value)
|
|
679
|
+
throw new RequestError(400, `${name} is required`);
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
function requiredMessage(value) {
|
|
683
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
684
|
+
throw new RequestError(400, 'message is required');
|
|
685
|
+
}
|
|
686
|
+
return value;
|
|
687
|
+
}
|
|
688
|
+
function requiredInteger(value, name) {
|
|
689
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
690
|
+
throw new RequestError(400, `${name} must be an integer`);
|
|
691
|
+
}
|
|
692
|
+
return value;
|
|
693
|
+
}
|
|
694
|
+
function optionalAttachmentIds(value) {
|
|
695
|
+
if (value === undefined)
|
|
696
|
+
return [];
|
|
697
|
+
if (!Array.isArray(value) || value.length > 16) {
|
|
698
|
+
throw new RequestError(400, 'attachments must be an array with at most 16 ids');
|
|
699
|
+
}
|
|
700
|
+
return value.map((item) => requiredString(item, 'attachments'));
|
|
701
|
+
}
|
|
702
|
+
function normalizedMediaType(value) {
|
|
703
|
+
const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
|
|
704
|
+
return mediaType || undefined;
|
|
705
|
+
}
|
|
706
|
+
function decodeHeader(value) {
|
|
707
|
+
const raw = stringHeader(value);
|
|
708
|
+
if (!raw)
|
|
709
|
+
return '';
|
|
710
|
+
try {
|
|
711
|
+
return decodeURIComponent(raw);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return raw;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function cleanQuery(value) {
|
|
718
|
+
const text = String(value || '').trim();
|
|
719
|
+
return text || undefined;
|
|
720
|
+
}
|
|
721
|
+
function boundedLimit(value) {
|
|
722
|
+
if (!value)
|
|
723
|
+
return undefined;
|
|
724
|
+
const number = Number(value);
|
|
725
|
+
if (!Number.isInteger(number) || number < 1 || number > 200) {
|
|
726
|
+
throw new RequestError(400, 'limit must be an integer between 1 and 200');
|
|
727
|
+
}
|
|
728
|
+
return number;
|
|
729
|
+
}
|
|
730
|
+
function optionalRunState(value) {
|
|
731
|
+
if (!value)
|
|
732
|
+
return undefined;
|
|
733
|
+
const states = new Set([
|
|
734
|
+
'created',
|
|
735
|
+
'running',
|
|
736
|
+
'input_required',
|
|
737
|
+
'permission_required',
|
|
738
|
+
'completed',
|
|
739
|
+
'failed',
|
|
740
|
+
'canceled'
|
|
741
|
+
]);
|
|
742
|
+
if (!states.has(value))
|
|
743
|
+
throw new RequestError(400, 'Invalid run state');
|
|
744
|
+
return value;
|
|
745
|
+
}
|
|
746
|
+
function optionalRawString(value, name) {
|
|
747
|
+
if (value === undefined || value === null || value === '')
|
|
748
|
+
return undefined;
|
|
749
|
+
if (typeof value !== 'string')
|
|
750
|
+
throw new RequestError(400, `${name} must be a string`);
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
753
|
+
function optionalString(value) {
|
|
754
|
+
if (value === undefined || value === null)
|
|
755
|
+
return undefined;
|
|
756
|
+
if (typeof value !== 'string')
|
|
757
|
+
throw new RequestError(400, 'Expected a string');
|
|
758
|
+
return value.trim() || undefined;
|
|
759
|
+
}
|
|
760
|
+
function optionalBoolean(value, name) {
|
|
761
|
+
if (value === undefined)
|
|
762
|
+
return undefined;
|
|
763
|
+
if (typeof value !== 'boolean')
|
|
764
|
+
throw new RequestError(400, `${name} must be boolean`);
|
|
765
|
+
return value;
|
|
766
|
+
}
|
|
767
|
+
function optionalNumber(value, name) {
|
|
768
|
+
if (value === undefined)
|
|
769
|
+
return undefined;
|
|
770
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
771
|
+
throw new RequestError(400, `${name} must be an integer`);
|
|
772
|
+
}
|
|
773
|
+
return value;
|
|
774
|
+
}
|
|
775
|
+
function requiredStringArray(value, name, allowEmpty = false) {
|
|
776
|
+
if (!Array.isArray(value))
|
|
777
|
+
throw new RequestError(400, `${name} must be an array of strings`);
|
|
778
|
+
const result = value.map((item) => {
|
|
779
|
+
if (typeof item !== 'string' || !item.trim()) {
|
|
780
|
+
throw new RequestError(400, `${name} must contain non-empty strings`);
|
|
781
|
+
}
|
|
782
|
+
return item.trim();
|
|
783
|
+
});
|
|
784
|
+
if (!allowEmpty && !result.length) {
|
|
785
|
+
throw new RequestError(400, `${name} must contain at least one value`);
|
|
786
|
+
}
|
|
787
|
+
return result;
|
|
788
|
+
}
|
|
789
|
+
function requiredQuery(url, name) {
|
|
790
|
+
const value = url.searchParams.get(name)?.trim();
|
|
791
|
+
if (!value)
|
|
792
|
+
throw new RequestError(400, `${name} is required`);
|
|
793
|
+
return value;
|
|
794
|
+
}
|
|
795
|
+
function optionalContentLength(request) {
|
|
796
|
+
const raw = stringHeader(request.headers['content-length']);
|
|
797
|
+
if (!raw)
|
|
798
|
+
return undefined;
|
|
799
|
+
const value = Number(raw);
|
|
800
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
801
|
+
throw new RequestError(400, 'Content-Length is invalid');
|
|
802
|
+
}
|
|
803
|
+
return value;
|
|
804
|
+
}
|
|
805
|
+
function cookieValue(request, name) {
|
|
806
|
+
const cookie = stringHeader(request.headers.cookie);
|
|
807
|
+
for (const part of cookie.split(';')) {
|
|
808
|
+
const index = part.indexOf('=');
|
|
809
|
+
if (index < 0 || part.slice(0, index).trim() !== name)
|
|
810
|
+
continue;
|
|
811
|
+
return part.slice(index + 1).trim();
|
|
812
|
+
}
|
|
813
|
+
return '';
|
|
814
|
+
}
|
|
815
|
+
function adminCookie(value, ttlMs, config) {
|
|
816
|
+
return [
|
|
817
|
+
`${ADMIN_COOKIE}=${value}`,
|
|
818
|
+
'Path=/',
|
|
819
|
+
'HttpOnly',
|
|
820
|
+
'SameSite=Strict',
|
|
821
|
+
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
|
822
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
823
|
+
].join('; ');
|
|
824
|
+
}
|
|
825
|
+
function clearAdminCookie(config) {
|
|
826
|
+
return [
|
|
827
|
+
`${ADMIN_COOKIE}=`,
|
|
828
|
+
'Path=/',
|
|
829
|
+
'HttpOnly',
|
|
830
|
+
'SameSite=Strict',
|
|
831
|
+
'Max-Age=0',
|
|
832
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
833
|
+
].join('; ');
|
|
834
|
+
}
|
|
835
|
+
function isMutating(method) {
|
|
836
|
+
return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
837
|
+
}
|
|
838
|
+
function remoteKey(request) {
|
|
839
|
+
return request.socket.remoteAddress || 'unknown';
|
|
840
|
+
}
|
|
152
841
|
function stringHeader(value) {
|
|
153
842
|
return Array.isArray(value) ? value[0] || '' : value || '';
|
|
154
843
|
}
|
|
844
|
+
function safeEqual(left, right) {
|
|
845
|
+
const a = Buffer.from(left);
|
|
846
|
+
const b = Buffer.from(right);
|
|
847
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
848
|
+
}
|
|
849
|
+
function safePath(value) {
|
|
850
|
+
try {
|
|
851
|
+
return new URL(value || '/', 'http://localhost').pathname;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return '/invalid-url';
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function decodePathSegment(value) {
|
|
858
|
+
try {
|
|
859
|
+
return decodeURIComponent(value);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
throw new RequestError(400, 'URL path is invalid');
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function ownedSession(sessions, sessionId, principal) {
|
|
866
|
+
const session = sessions.get(sessionId);
|
|
867
|
+
if (!sessions.owns(sessionId, principal.id)) {
|
|
868
|
+
throw new RequestError(404, 'Session not found');
|
|
869
|
+
}
|
|
870
|
+
assertAgentScope(principal, session.agentId);
|
|
871
|
+
return session;
|
|
872
|
+
}
|
|
873
|
+
async function streamDownload(response, handle, name, size, mediaType) {
|
|
874
|
+
try {
|
|
875
|
+
response.writeHead(200, {
|
|
876
|
+
'Content-Type': mediaType,
|
|
877
|
+
'Content-Length': size,
|
|
878
|
+
'Content-Disposition': contentDisposition(name),
|
|
879
|
+
'Cache-Control': 'no-store',
|
|
880
|
+
'X-Content-Type-Options': 'nosniff'
|
|
881
|
+
});
|
|
882
|
+
await pipeline(handle.createReadStream({ autoClose: false }), response);
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
await handle.close().catch(() => undefined);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async function publishWorkspaceFiles(workspaceFiles, artifactStore, root, paths) {
|
|
889
|
+
const files = [];
|
|
890
|
+
const errors = [];
|
|
891
|
+
let firstFailure;
|
|
892
|
+
for (const value of paths) {
|
|
893
|
+
let file;
|
|
894
|
+
try {
|
|
895
|
+
file = await workspaceFiles.publishable(root, value);
|
|
896
|
+
files.push(await artifactStore.publishHandle(file.handle, file.name));
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
if (!(error instanceof WorkspaceFileError) && !(error instanceof ArtifactStoreError)) {
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
902
|
+
firstFailure ||= { status: error.status, message: error.message };
|
|
903
|
+
errors.push({ path: value, error: error.message });
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
await file?.handle.close().catch(() => undefined);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (!files.length && firstFailure) {
|
|
910
|
+
throw new RequestError(firstFailure.status, firstFailure.message);
|
|
911
|
+
}
|
|
912
|
+
return { files, errors };
|
|
913
|
+
}
|
|
914
|
+
async function materializeSessionArtifacts(session, store) {
|
|
915
|
+
const artifacts = await Promise.all(session.artifacts.map(async (artifact, index) => {
|
|
916
|
+
if (!artifact.bytesBase64)
|
|
917
|
+
return artifact;
|
|
918
|
+
let bytes;
|
|
919
|
+
try {
|
|
920
|
+
bytes = decodeArtifactBase64(artifact.bytesBase64);
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
return omittedArtifact(artifact, errorMessage(error));
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
const published = await store.publishBytes(bytes, artifact.filename || artifact.name || `artifact-${artifact.id || randomUUID()}`, artifact.mediaType, `${session.id}:${artifact.id || index}`);
|
|
927
|
+
return {
|
|
928
|
+
...artifact,
|
|
929
|
+
name: artifact.name || published.name,
|
|
930
|
+
filename: artifact.filename || published.name,
|
|
931
|
+
url: published.url,
|
|
932
|
+
bytesBase64: undefined,
|
|
933
|
+
metadata: {
|
|
934
|
+
...artifact.metadata,
|
|
935
|
+
publishedArtifactId: published.id,
|
|
936
|
+
size: published.size,
|
|
937
|
+
sha256: published.sha256,
|
|
938
|
+
expiresAt: published.expiresAt
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
if (error instanceof ArtifactStoreError && error.status === 413) {
|
|
944
|
+
return omittedArtifact(artifact, error.message);
|
|
945
|
+
}
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}));
|
|
949
|
+
return { ...session, artifacts };
|
|
950
|
+
}
|
|
951
|
+
function decodeArtifactBase64(value) {
|
|
952
|
+
const normalized = value.replace(/\s/g, '');
|
|
953
|
+
if (!normalized ||
|
|
954
|
+
normalized.length % 4 === 1 ||
|
|
955
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
956
|
+
throw new Error('Artifact contains invalid Base64 data');
|
|
957
|
+
}
|
|
958
|
+
const bytes = Buffer.from(normalized, 'base64');
|
|
959
|
+
if (bytes.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) {
|
|
960
|
+
throw new Error('Artifact contains invalid Base64 data');
|
|
961
|
+
}
|
|
962
|
+
return bytes;
|
|
963
|
+
}
|
|
964
|
+
function omittedArtifact(artifact, reason) {
|
|
965
|
+
return {
|
|
966
|
+
...artifact,
|
|
967
|
+
bytesBase64: undefined,
|
|
968
|
+
description: [artifact.description, `[binary artifact omitted: ${reason}]`]
|
|
969
|
+
.filter(Boolean)
|
|
970
|
+
.join('\n')
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function errorStatus(error) {
|
|
974
|
+
if (error instanceof RunNotFoundError)
|
|
975
|
+
return 404;
|
|
976
|
+
if (error instanceof SessionNotFoundError)
|
|
977
|
+
return 404;
|
|
978
|
+
if (error instanceof SessionRequestError)
|
|
979
|
+
return error.status;
|
|
980
|
+
if (error instanceof ControlPlaneError)
|
|
981
|
+
return error.status;
|
|
982
|
+
if (error instanceof ArtifactStoreError)
|
|
983
|
+
return error.status;
|
|
984
|
+
if (error instanceof WorkspaceFileError)
|
|
985
|
+
return error.status;
|
|
986
|
+
if (error instanceof RequestError)
|
|
987
|
+
return error.status;
|
|
988
|
+
return 500;
|
|
989
|
+
}
|
|
990
|
+
function errorMessage(error) {
|
|
991
|
+
return error instanceof Error ? error.message : String(error);
|
|
992
|
+
}
|
|
155
993
|
function writeJson(response, status, value) {
|
|
156
994
|
const body = `${JSON.stringify(value)}\n`;
|
|
157
995
|
response.writeHead(status, {
|
|
158
996
|
'Content-Type': 'application/json; charset=utf-8',
|
|
159
997
|
'Content-Length': Buffer.byteLength(body),
|
|
160
|
-
'Cache-Control': 'no-store'
|
|
998
|
+
'Cache-Control': 'no-store',
|
|
999
|
+
'X-Content-Type-Options': 'nosniff'
|
|
161
1000
|
});
|
|
162
1001
|
response.end(body);
|
|
163
1002
|
}
|
|
@@ -168,3 +1007,34 @@ class RequestError extends Error {
|
|
|
168
1007
|
this.status = status;
|
|
169
1008
|
}
|
|
170
1009
|
}
|
|
1010
|
+
class FailureRateLimiter {
|
|
1011
|
+
maximum;
|
|
1012
|
+
windowMs;
|
|
1013
|
+
blockMs;
|
|
1014
|
+
entries = new Map();
|
|
1015
|
+
constructor(maximum, windowMs, blockMs) {
|
|
1016
|
+
this.maximum = maximum;
|
|
1017
|
+
this.windowMs = windowMs;
|
|
1018
|
+
this.blockMs = blockMs;
|
|
1019
|
+
}
|
|
1020
|
+
assertAllowed(key) {
|
|
1021
|
+
const entry = this.entries.get(key);
|
|
1022
|
+
if (entry?.blockedUntil && entry.blockedUntil > Date.now()) {
|
|
1023
|
+
throw new RequestError(429, 'Too many authentication attempts');
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
failure(key) {
|
|
1027
|
+
const now = Date.now();
|
|
1028
|
+
const previous = this.entries.get(key);
|
|
1029
|
+
const entry = !previous || now - previous.windowStartedAt >= this.windowMs
|
|
1030
|
+
? { count: 0, windowStartedAt: now, blockedUntil: 0 }
|
|
1031
|
+
: previous;
|
|
1032
|
+
entry.count += 1;
|
|
1033
|
+
if (entry.count >= this.maximum)
|
|
1034
|
+
entry.blockedUntil = now + this.blockMs;
|
|
1035
|
+
this.entries.set(key, entry);
|
|
1036
|
+
}
|
|
1037
|
+
success(key) {
|
|
1038
|
+
this.entries.delete(key);
|
|
1039
|
+
}
|
|
1040
|
+
}
|