nexus-agentd 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -14
- package/dist/a2a/runtime.d.ts +4 -1
- package/dist/a2a/runtime.js +213 -10
- package/dist/acp/runtime.js +13 -6
- package/dist/artifact-store.d.ts +168 -0
- package/dist/artifact-store.js +1111 -0
- package/dist/cli.js +13 -14
- 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 +41 -157
- 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.js +10 -1
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +90 -7
- package/dist/run-store.js +254 -22
- 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 +737 -419
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +298 -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.d.ts +65 -7
- package/dist/session.js +256 -109
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +46 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -9
- package/dist/webui/icons.d.ts +1 -0
- package/dist/webui/icons.js +1 -0
- package/dist/webui/markup.js +3 -0
- package/dist/webui/styles.js +105 -15
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +19 -0
- package/package.json +3 -2
package/dist/run-store.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
5
|
+
export { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
6
|
+
/** Coarse bucket used by the console filter and list grouping. */
|
|
7
|
+
export function artifactKind(mediaType) {
|
|
8
|
+
const type = String(mediaType || '').toLowerCase();
|
|
9
|
+
if (type.startsWith('image/'))
|
|
10
|
+
return 'image';
|
|
11
|
+
if (type.startsWith('video/'))
|
|
12
|
+
return 'video';
|
|
13
|
+
if (type.startsWith('audio/'))
|
|
14
|
+
return 'audio';
|
|
15
|
+
return 'file';
|
|
16
|
+
}
|
|
4
17
|
const STATES = new Set([
|
|
5
18
|
'created',
|
|
6
19
|
'running',
|
|
@@ -14,17 +27,43 @@ const MAX_OUTPUT_CHARS = 256 * 1024;
|
|
|
14
27
|
const MAX_SUMMARY_CHARS = 600;
|
|
15
28
|
export class RunStore {
|
|
16
29
|
filePath;
|
|
17
|
-
maxRuns;
|
|
18
|
-
maxTaskChars;
|
|
19
30
|
runs = new Map();
|
|
20
31
|
initialized = false;
|
|
21
32
|
dirty = false;
|
|
22
33
|
persistTimer;
|
|
23
34
|
writeQueue = Promise.resolve();
|
|
24
|
-
|
|
35
|
+
artifactStore;
|
|
36
|
+
maxRuns;
|
|
37
|
+
maxTaskChars;
|
|
38
|
+
historyRetentionDays;
|
|
39
|
+
historyMaxBytes;
|
|
40
|
+
now;
|
|
41
|
+
lastWriteMs = 0;
|
|
42
|
+
writeFailures = 0;
|
|
43
|
+
historyBytes = 0;
|
|
44
|
+
constructor(filePath, maxRunsOrOptions = 1000, maxTaskCharsOrOptions = undefined, suppliedOptions = {}) {
|
|
25
45
|
this.filePath = filePath;
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
const positionalOptions = typeof maxRunsOrOptions === 'object' ? maxRunsOrOptions : { ...suppliedOptions };
|
|
47
|
+
if (typeof maxTaskCharsOrOptions === 'object') {
|
|
48
|
+
Object.assign(positionalOptions, maxTaskCharsOrOptions);
|
|
49
|
+
}
|
|
50
|
+
this.maxRuns = positiveInteger(typeof maxRunsOrOptions === 'number'
|
|
51
|
+
? maxRunsOrOptions
|
|
52
|
+
: positionalOptions.maxRuns, 1000);
|
|
53
|
+
this.maxTaskChars = positiveInteger(typeof maxTaskCharsOrOptions === 'number'
|
|
54
|
+
? maxTaskCharsOrOptions
|
|
55
|
+
: maxTaskCharsOrOptions?.maxTaskChars ?? positionalOptions.maxTaskChars, 1024 * 1024);
|
|
56
|
+
this.historyRetentionDays = nonNegativeNumber(positionalOptions.historyRetentionDays, undefined);
|
|
57
|
+
this.historyMaxBytes = nonNegativeInteger(positionalOptions.historyMaxBytes, undefined);
|
|
58
|
+
this.now = positionalOptions.now || Date.now;
|
|
59
|
+
const artifactRoot = positionalOptions.rootDir ||
|
|
60
|
+
positionalOptions.artifactRootDir ||
|
|
61
|
+
artifactStorePathForRunStore(filePath);
|
|
62
|
+
this.artifactStore = new ArtifactStore(artifactRoot, {
|
|
63
|
+
...positionalOptions,
|
|
64
|
+
onViewsChanged: (runId, views) => this.applyArtifactViews(runId, views),
|
|
65
|
+
isRunActive: (runId) => isActive(this.runs.get(runId)?.state)
|
|
66
|
+
});
|
|
28
67
|
}
|
|
29
68
|
async init() {
|
|
30
69
|
if (this.initialized)
|
|
@@ -52,8 +91,7 @@ export class RunStore {
|
|
|
52
91
|
}
|
|
53
92
|
}
|
|
54
93
|
}
|
|
55
|
-
|
|
56
|
-
const now = Date.now();
|
|
94
|
+
const now = this.now();
|
|
57
95
|
for (const [id, run] of this.runs) {
|
|
58
96
|
if (!isActive(run.state))
|
|
59
97
|
continue;
|
|
@@ -68,6 +106,16 @@ export class RunStore {
|
|
|
68
106
|
});
|
|
69
107
|
this.dirty = true;
|
|
70
108
|
}
|
|
109
|
+
await this.artifactStore.init();
|
|
110
|
+
for (const [id, run] of this.runs) {
|
|
111
|
+
const views = this.artifactStore.restoreRun(id, run.artifacts);
|
|
112
|
+
if (JSON.stringify(run.artifacts) !== JSON.stringify(views)) {
|
|
113
|
+
run.artifacts = views;
|
|
114
|
+
run.artifactCount = views.length;
|
|
115
|
+
this.dirty = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.initialized = true;
|
|
71
119
|
this.prune();
|
|
72
120
|
if (!(await fileExists(this.filePath)))
|
|
73
121
|
this.dirty = true;
|
|
@@ -76,7 +124,7 @@ export class RunStore {
|
|
|
76
124
|
}
|
|
77
125
|
create(input) {
|
|
78
126
|
this.assertInitialized();
|
|
79
|
-
const now =
|
|
127
|
+
const now = this.now();
|
|
80
128
|
const taskTruncated = input.task.length > this.maxTaskChars;
|
|
81
129
|
const run = {
|
|
82
130
|
id: randomUUID(),
|
|
@@ -89,6 +137,7 @@ export class RunStore {
|
|
|
89
137
|
ownerKeyId: input.ownerKeyId,
|
|
90
138
|
task: taskTruncated ? input.task.slice(0, this.maxTaskChars) : input.task,
|
|
91
139
|
taskTruncated: taskTruncated || undefined,
|
|
140
|
+
retryOfRunId: input.retryOfRunId,
|
|
92
141
|
inputAttachmentCount: input.inputAttachmentCount || undefined,
|
|
93
142
|
state: 'running',
|
|
94
143
|
progress: { phase: '已接收', message: '任务已交给智能体' },
|
|
@@ -109,10 +158,15 @@ export class RunStore {
|
|
|
109
158
|
const output = patch.output === undefined
|
|
110
159
|
? current.output
|
|
111
160
|
: clipTail(patch.output, MAX_OUTPUT_CHARS);
|
|
112
|
-
const updatedAt = patch.updatedAt ??
|
|
161
|
+
const updatedAt = patch.updatedAt ?? this.now();
|
|
162
|
+
const normalizedPatch = structuredClone(patch);
|
|
163
|
+
if (patch.artifacts !== undefined) {
|
|
164
|
+
normalizedPatch.artifacts = this.artifactStore.mergeViews(id, patch.artifacts, false);
|
|
165
|
+
normalizedPatch.artifactCount = normalizedPatch.artifacts.length;
|
|
166
|
+
}
|
|
113
167
|
const next = {
|
|
114
168
|
...current,
|
|
115
|
-
...
|
|
169
|
+
...normalizedPatch,
|
|
116
170
|
output,
|
|
117
171
|
resultSummary: output ? summarize(output) : current.resultSummary,
|
|
118
172
|
updatedAt
|
|
@@ -120,11 +174,119 @@ export class RunStore {
|
|
|
120
174
|
if (patch.endedAt !== undefined) {
|
|
121
175
|
next.durationMs = Math.max(0, patch.endedAt - next.startedAt);
|
|
122
176
|
}
|
|
123
|
-
|
|
177
|
+
// Preserve creation order as the stable tie-breaker for same-ms tasks.
|
|
124
178
|
this.runs.set(id, next);
|
|
125
179
|
this.changed();
|
|
126
180
|
return publicDetail(next);
|
|
127
181
|
}
|
|
182
|
+
/** Queue payload snapshots without blocking the protocol sink. */
|
|
183
|
+
recordArtifacts(runId, artifacts) {
|
|
184
|
+
this.assertInitialized();
|
|
185
|
+
if (!this.runs.has(runId))
|
|
186
|
+
return undefined;
|
|
187
|
+
return this.artifactStore.recordArtifacts(runId, artifacts);
|
|
188
|
+
}
|
|
189
|
+
artifactViews(runId) {
|
|
190
|
+
this.assertInitialized();
|
|
191
|
+
if (!this.runs.has(runId))
|
|
192
|
+
return undefined;
|
|
193
|
+
return this.artifactStore.views(runId);
|
|
194
|
+
}
|
|
195
|
+
async readArtifact(runId, artifactId) {
|
|
196
|
+
this.assertInitialized();
|
|
197
|
+
if (!this.runs.has(runId))
|
|
198
|
+
return undefined;
|
|
199
|
+
return this.artifactStore.readArtifact(runId, artifactId);
|
|
200
|
+
}
|
|
201
|
+
/** Flat, newest-first artifact index across all retained runs. */
|
|
202
|
+
listArtifacts(query = {}) {
|
|
203
|
+
this.assertInitialized();
|
|
204
|
+
const needle = String(query.query || '').trim().toLowerCase();
|
|
205
|
+
const limit = Math.min(200, Math.max(1, Number(query.limit) || 50));
|
|
206
|
+
const offset = Math.max(0, Math.floor(Number(query.offset) || 0));
|
|
207
|
+
const rows = [];
|
|
208
|
+
for (const run of this.runs.values()) {
|
|
209
|
+
for (const view of run.artifacts || []) {
|
|
210
|
+
if (!view.id)
|
|
211
|
+
continue;
|
|
212
|
+
if (query.agentId && run.agentId !== query.agentId)
|
|
213
|
+
continue;
|
|
214
|
+
const kind = artifactKind(view.mediaType);
|
|
215
|
+
if (query.kind && query.kind !== 'all' && kind !== query.kind)
|
|
216
|
+
continue;
|
|
217
|
+
if (needle) {
|
|
218
|
+
const haystack = `${view.name || ''} ${view.filename || ''}`.toLowerCase();
|
|
219
|
+
if (!haystack.includes(needle))
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
rows.push({
|
|
223
|
+
id: view.id,
|
|
224
|
+
runId: run.id,
|
|
225
|
+
agentId: run.agentId,
|
|
226
|
+
agentName: run.agentName,
|
|
227
|
+
runState: run.state,
|
|
228
|
+
runStartedAt: run.startedAt,
|
|
229
|
+
name: view.name,
|
|
230
|
+
filename: view.filename,
|
|
231
|
+
mediaType: view.mediaType,
|
|
232
|
+
size: view.size,
|
|
233
|
+
kind,
|
|
234
|
+
downloadable: view.downloadable === true,
|
|
235
|
+
storageStatus: view.storageStatus,
|
|
236
|
+
createdAt: view.createdAt
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
rows.sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0));
|
|
241
|
+
return { artifacts: rows.slice(offset, offset + limit), total: rows.length };
|
|
242
|
+
}
|
|
243
|
+
/** Delete one artifact's payload and record entry; the run record stays. */
|
|
244
|
+
async removeArtifact(runId, artifactId) {
|
|
245
|
+
this.assertInitialized();
|
|
246
|
+
if (!this.runs.has(runId))
|
|
247
|
+
return false;
|
|
248
|
+
return this.artifactStore.removeArtifact(runId, artifactId);
|
|
249
|
+
}
|
|
250
|
+
getOwnerKeyId(runId) {
|
|
251
|
+
this.assertInitialized();
|
|
252
|
+
return this.runs.get(runId)?.ownerKeyId;
|
|
253
|
+
}
|
|
254
|
+
metrics() {
|
|
255
|
+
const artifactStoreStats = this.artifactStore.stats();
|
|
256
|
+
return {
|
|
257
|
+
// This is the last successfully persisted JSON size. A dirty store
|
|
258
|
+
// reports that size until its queued write completes, avoiding a
|
|
259
|
+
// full-history serialization on every metrics poll.
|
|
260
|
+
historyBytes: this.historyBytes,
|
|
261
|
+
retainedRuns: this.runs.size,
|
|
262
|
+
pendingWrites: (this.dirty ? 1 : 0) + artifactStoreStats.pendingWrites,
|
|
263
|
+
lastWriteMs: this.lastWriteMs,
|
|
264
|
+
writeFailures: this.writeFailures,
|
|
265
|
+
artifactStoreStats
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
stats() {
|
|
269
|
+
return this.metrics();
|
|
270
|
+
}
|
|
271
|
+
setRetryOfRunId(runId, retryOfRunId) {
|
|
272
|
+
return this.update(runId, { retryOfRunId });
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Remove a terminal run record and its stored artifacts. Active runs are
|
|
276
|
+
* refused so a live turn cannot lose its progress tracking.
|
|
277
|
+
*/
|
|
278
|
+
deleteRun(id) {
|
|
279
|
+
this.assertInitialized();
|
|
280
|
+
const run = this.runs.get(id);
|
|
281
|
+
if (!run)
|
|
282
|
+
return undefined;
|
|
283
|
+
if (isActive(run.state))
|
|
284
|
+
return undefined;
|
|
285
|
+
this.runs.delete(id);
|
|
286
|
+
this.artifactStore.removeRun(id);
|
|
287
|
+
this.changed();
|
|
288
|
+
return publicDetail(run);
|
|
289
|
+
}
|
|
128
290
|
get(id) {
|
|
129
291
|
this.assertInitialized();
|
|
130
292
|
const run = this.runs.get(id);
|
|
@@ -134,8 +296,10 @@ export class RunStore {
|
|
|
134
296
|
this.assertInitialized();
|
|
135
297
|
const needle = String(query.query || '').trim().toLowerCase();
|
|
136
298
|
const limit = Math.min(200, Math.max(1, Number(query.limit) || 50));
|
|
299
|
+
const offset = Math.max(0, Math.floor(Number(query.offset) || 0));
|
|
137
300
|
const matched = Array.from(this.runs.values())
|
|
138
301
|
.reverse()
|
|
302
|
+
.sort((left, right) => right.startedAt - left.startedAt)
|
|
139
303
|
.filter((run) => !query.agentId || run.agentId === query.agentId)
|
|
140
304
|
.filter((run) => !query.sessionId || run.sessionId === query.sessionId)
|
|
141
305
|
.filter((run) => !query.state || run.state === query.state)
|
|
@@ -153,7 +317,7 @@ export class RunStore {
|
|
|
153
317
|
if (run.state === 'failed')
|
|
154
318
|
stats.failed++;
|
|
155
319
|
}
|
|
156
|
-
const runs = matched.slice(
|
|
320
|
+
const runs = matched.slice(offset, offset + limit).map(publicView);
|
|
157
321
|
return { runs, total, stats };
|
|
158
322
|
}
|
|
159
323
|
async flush() {
|
|
@@ -161,6 +325,11 @@ export class RunStore {
|
|
|
161
325
|
if (this.persistTimer)
|
|
162
326
|
clearTimeout(this.persistTimer);
|
|
163
327
|
this.persistTimer = undefined;
|
|
328
|
+
if (this.dirty)
|
|
329
|
+
await this.persist();
|
|
330
|
+
await this.artifactStore.flush();
|
|
331
|
+
// Successful artifact commits update run metadata via the callback
|
|
332
|
+
// above, so persist those storage facts as well.
|
|
164
333
|
if (this.dirty)
|
|
165
334
|
await this.persist();
|
|
166
335
|
await this.writeQueue;
|
|
@@ -174,6 +343,7 @@ export class RunStore {
|
|
|
174
343
|
!Array.isArray(parsed.runs)) {
|
|
175
344
|
throw new InvalidRunFileError('Invalid Agent Nexus run history file');
|
|
176
345
|
}
|
|
346
|
+
this.historyBytes = Buffer.byteLength(raw);
|
|
177
347
|
for (const value of parsed.runs) {
|
|
178
348
|
const run = normalizeRun(value);
|
|
179
349
|
if (run)
|
|
@@ -198,12 +368,39 @@ export class RunStore {
|
|
|
198
368
|
this.persistTimer.unref?.();
|
|
199
369
|
}
|
|
200
370
|
prune() {
|
|
201
|
-
|
|
202
|
-
const terminal = Array.from(this.runs.entries())
|
|
371
|
+
const removeOldestTerminal = (predicate) => {
|
|
372
|
+
const terminal = Array.from(this.runs.entries())
|
|
373
|
+
.filter(([, run]) => !isActive(run.state) && predicate(run))
|
|
374
|
+
.sort(([, left], [, right]) => left.updatedAt - right.updatedAt)[0];
|
|
203
375
|
if (!terminal)
|
|
204
|
-
|
|
376
|
+
return false;
|
|
205
377
|
this.runs.delete(terminal[0]);
|
|
378
|
+
this.artifactStore.removeRun(terminal[0]);
|
|
206
379
|
this.dirty = true;
|
|
380
|
+
return true;
|
|
381
|
+
};
|
|
382
|
+
while (this.runs.size > this.maxRuns) {
|
|
383
|
+
if (!removeOldestTerminal(() => true))
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
if (this.historyRetentionDays !== undefined) {
|
|
387
|
+
const cutoff = this.now() - this.historyRetentionDays * 24 * 60 * 60 * 1000;
|
|
388
|
+
while (removeOldestTerminal((run) => (run.endedAt ?? run.updatedAt) <= cutoff)) { }
|
|
389
|
+
}
|
|
390
|
+
if (this.historyMaxBytes !== undefined) {
|
|
391
|
+
const historyBytes = () => Buffer.byteLength(JSON.stringify(Array.from(this.runs.values())));
|
|
392
|
+
while (historyBytes() > this.historyMaxBytes) {
|
|
393
|
+
if (!removeOldestTerminal(() => true)) {
|
|
394
|
+
console.error(JSON.stringify({
|
|
395
|
+
level: 'warn',
|
|
396
|
+
event: 'run_history_budget_exceeded',
|
|
397
|
+
bytes: historyBytes(),
|
|
398
|
+
budget: this.historyMaxBytes,
|
|
399
|
+
activeRunsProtected: Array.from(this.runs.values()).filter((run) => isActive(run.state)).length
|
|
400
|
+
}));
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
207
404
|
}
|
|
208
405
|
}
|
|
209
406
|
persist() {
|
|
@@ -216,13 +413,22 @@ export class RunStore {
|
|
|
216
413
|
};
|
|
217
414
|
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
|
218
415
|
const write = async () => {
|
|
416
|
+
const startedAt = this.now();
|
|
219
417
|
const temp = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
418
|
+
try {
|
|
419
|
+
await writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600 });
|
|
420
|
+
await rename(temp, this.filePath);
|
|
421
|
+
await chmod(this.filePath, 0o600).catch(() => undefined);
|
|
422
|
+
this.historyBytes = Buffer.byteLength(serialized);
|
|
423
|
+
this.lastWriteMs = Math.max(0, this.now() - startedAt);
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
await unlink(temp).catch(() => undefined);
|
|
427
|
+
}
|
|
223
428
|
};
|
|
224
429
|
const next = this.writeQueue.then(write, write).catch((error) => {
|
|
225
430
|
this.dirty = true;
|
|
431
|
+
this.writeFailures++;
|
|
226
432
|
throw error;
|
|
227
433
|
});
|
|
228
434
|
this.writeQueue = next.catch(() => undefined);
|
|
@@ -232,6 +438,19 @@ export class RunStore {
|
|
|
232
438
|
if (!this.initialized)
|
|
233
439
|
throw new Error('RunStore is not initialized');
|
|
234
440
|
}
|
|
441
|
+
applyArtifactViews(runId, views) {
|
|
442
|
+
const run = this.runs.get(runId);
|
|
443
|
+
if (!run)
|
|
444
|
+
return;
|
|
445
|
+
run.artifacts = structuredClone(views);
|
|
446
|
+
run.artifactCount = run.artifacts.length;
|
|
447
|
+
run.updatedAt = this.now();
|
|
448
|
+
if (!this.initialized) {
|
|
449
|
+
this.dirty = true;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.changed();
|
|
453
|
+
}
|
|
235
454
|
}
|
|
236
455
|
export function runStorePathForConfig(configPath) {
|
|
237
456
|
const absolute = path.resolve(configPath);
|
|
@@ -263,13 +482,26 @@ function normalizeRun(value) {
|
|
|
263
482
|
artifactCount: Number.isFinite(run.artifactCount) ? run.artifactCount : 0
|
|
264
483
|
};
|
|
265
484
|
}
|
|
485
|
+
function positiveInteger(value, fallback) {
|
|
486
|
+
return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : fallback;
|
|
487
|
+
}
|
|
488
|
+
function nonNegativeInteger(value, fallback) {
|
|
489
|
+
return Number.isFinite(value) && Number(value) >= 0 ? Math.floor(Number(value)) : fallback;
|
|
490
|
+
}
|
|
491
|
+
function nonNegativeNumber(value, fallback) {
|
|
492
|
+
return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : fallback;
|
|
493
|
+
}
|
|
266
494
|
function publicView(run) {
|
|
267
|
-
const { ownerKeyId: _ownerKeyId, output: _output, artifacts: _artifacts, ...view } = run;
|
|
268
|
-
return structuredClone(view)
|
|
495
|
+
const { ownerKeyId: _ownerKeyId, task, output: _output, artifacts: _artifacts, ...view } = run;
|
|
496
|
+
return structuredClone({ ...view, taskPreview: task.slice(0, 240),
|
|
497
|
+
// Remote errors/progress can be large too; full values remain in details.
|
|
498
|
+
error: view.error?.slice(0, 600),
|
|
499
|
+
progress: { ...view.progress, phase: view.progress.phase.slice(0, 120), message: view.progress.message?.slice(0, 600) }
|
|
500
|
+
});
|
|
269
501
|
}
|
|
270
502
|
function publicDetail(run) {
|
|
271
503
|
const { ownerKeyId: _ownerKeyId, ...detail } = run;
|
|
272
|
-
return structuredClone(detail);
|
|
504
|
+
return structuredClone({ ...detail, taskPreview: run.task.slice(0, 240) });
|
|
273
505
|
}
|
|
274
506
|
function summarize(value) {
|
|
275
507
|
const compact = value.replace(/\s+/g, ' ').trim();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { AdminSessionStore } from '../auth.js';
|
|
3
|
+
import type { AgentdControlPlane } from '../control-plane.js';
|
|
4
|
+
import type { AgentdApiKeyPrincipal, AgentdConfig } from '../types.js';
|
|
5
|
+
export declare const ADMIN_COOKIE = "agent_nexus_admin";
|
|
6
|
+
export interface FailureLimiter {
|
|
7
|
+
assertAllowed(key: string): void;
|
|
8
|
+
failure(key: string): void;
|
|
9
|
+
success(key: string): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function authenticateAdmin(request: IncomingMessage, sessions: AdminSessionStore): void;
|
|
12
|
+
export declare function authenticateApiKey(request: IncomingMessage, config: AgentdConfig, controlPlane: AgentdControlPlane | undefined, limiter: FailureLimiter): AgentdApiKeyPrincipal;
|
|
13
|
+
export declare function fallbackApiKey(config: AgentdConfig, secret: string): AgentdApiKeyPrincipal | undefined;
|
|
14
|
+
export declare function assertAgentScope(principal: AgentdApiKeyPrincipal, agentId: string): void;
|
|
15
|
+
export declare function isInitialized(config: AgentdConfig, controlPlane?: AgentdControlPlane): boolean;
|
|
16
|
+
export declare function needsAdminSetup(config: AgentdConfig, controlPlane?: AgentdControlPlane): any;
|
|
17
|
+
export declare function consoleHost(request: IncomingMessage): import("url").URL;
|
|
18
|
+
export declare function isDirectConsoleHost(request: IncomingMessage, config: AgentdConfig, hostname: string): boolean;
|
|
19
|
+
export declare function assertConsoleHost(request: IncomingMessage, config: AgentdConfig): void;
|
|
20
|
+
export declare function assertTrustedOrigin(request: IncomingMessage, config: AgentdConfig): void;
|
|
21
|
+
export declare function originMatchesHost(origin: string, request: IncomingMessage): boolean;
|
|
22
|
+
export declare function cookieValue(request: IncomingMessage, name: string): string;
|
|
23
|
+
export declare function adminCookie(value: string, ttlMs: number, config: AgentdConfig): string;
|
|
24
|
+
export declare function clearAdminCookie(config: AgentdConfig): string;
|
|
25
|
+
export declare function isMutating(method?: string): method is "POST" | "PUT" | "PATCH" | "DELETE";
|
|
26
|
+
export declare function remoteKey(request: IncomingMessage): string;
|
|
27
|
+
export declare function stringHeader(value: string | string[] | undefined): string;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { RequestError } from './validation.js';
|
|
3
|
+
export const ADMIN_COOKIE = 'agent_nexus_admin';
|
|
4
|
+
export function authenticateAdmin(request, sessions) {
|
|
5
|
+
const id = cookieValue(request, ADMIN_COOKIE);
|
|
6
|
+
if (!id || !sessions.has(id))
|
|
7
|
+
throw new RequestError(401, 'Admin session is required');
|
|
8
|
+
}
|
|
9
|
+
export function authenticateApiKey(request, config, controlPlane, limiter) {
|
|
10
|
+
const rateKey = remoteKey(request);
|
|
11
|
+
limiter.assertAllowed(rateKey);
|
|
12
|
+
const value = stringHeader(request.headers.authorization);
|
|
13
|
+
if (!value.startsWith('Bearer ')) {
|
|
14
|
+
limiter.failure(rateKey);
|
|
15
|
+
throw new RequestError(401, 'Bearer API Key is required');
|
|
16
|
+
}
|
|
17
|
+
const secret = value.slice(7);
|
|
18
|
+
// A live control plane is authoritative, including a negative decision.
|
|
19
|
+
const principal = controlPlane
|
|
20
|
+
? controlPlane.authenticateApiKey(secret)
|
|
21
|
+
: fallbackApiKey(config, secret);
|
|
22
|
+
if (!principal) {
|
|
23
|
+
limiter.failure(rateKey);
|
|
24
|
+
throw new RequestError(401, 'Invalid API Key');
|
|
25
|
+
}
|
|
26
|
+
limiter.success(rateKey);
|
|
27
|
+
return principal;
|
|
28
|
+
}
|
|
29
|
+
export function fallbackApiKey(config, secret) {
|
|
30
|
+
for (const key of config.apiKeys || []) {
|
|
31
|
+
if (key.enabled && safeEqual(key.secret, secret)) {
|
|
32
|
+
return { id: key.id, scope: structuredClone(key.scope) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!config.apiKeys?.some((key) => key.id === 'legacy') && config.authToken && safeEqual(config.authToken, secret)) {
|
|
36
|
+
return { id: 'legacy', scope: { allAgents: true, agentIds: [] } };
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
export function assertAgentScope(principal, agentId) {
|
|
41
|
+
if (!principal.scope.allAgents && !principal.scope.agentIds.includes(agentId)) {
|
|
42
|
+
throw new RequestError(403, 'API Key is not authorized for this agent');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isInitialized(config, controlPlane) {
|
|
46
|
+
return controlPlane?.isInitialized() ?? config.initialized;
|
|
47
|
+
}
|
|
48
|
+
export function needsAdminSetup(config, controlPlane) {
|
|
49
|
+
const value = controlPlane?.needsAdminSetup;
|
|
50
|
+
return typeof value === 'function' ? value.call(controlPlane) : !config.adminPasswordHash;
|
|
51
|
+
}
|
|
52
|
+
export function consoleHost(request) {
|
|
53
|
+
const host = stringHeader(request.headers.host);
|
|
54
|
+
try {
|
|
55
|
+
if (!/^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::\d{1,5})?$/.test(host))
|
|
56
|
+
throw new Error();
|
|
57
|
+
const url = new URL(`http://${host}`);
|
|
58
|
+
return url;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new RequestError(403, 'Console Host is not allowed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function isDirectConsoleHost(request, config, hostname) {
|
|
65
|
+
const local = request.socket.localAddress?.replace(/^::ffff:/, '');
|
|
66
|
+
const hosts = new Set(['localhost', '127.0.0.1', '[::1]', config.listen.host, local, local && `[${local}]`]);
|
|
67
|
+
hosts.delete('0.0.0.0');
|
|
68
|
+
hosts.delete('::');
|
|
69
|
+
return hosts.has(hostname);
|
|
70
|
+
}
|
|
71
|
+
export function assertConsoleHost(request, config) {
|
|
72
|
+
const url = consoleHost(request);
|
|
73
|
+
if (!isDirectConsoleHost(request, config, url.hostname) && !config.publicOrigins?.some((origin) => originMatchesHost(origin, request))) {
|
|
74
|
+
throw new RequestError(403, 'Console Host is not allowed');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function assertTrustedOrigin(request, config) {
|
|
78
|
+
const origin = stringHeader(request.headers.origin);
|
|
79
|
+
if (!origin)
|
|
80
|
+
throw new RequestError(403, 'Same-origin request is required');
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(origin);
|
|
84
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== origin)
|
|
85
|
+
throw new Error();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new RequestError(403, 'Request origin is invalid');
|
|
89
|
+
}
|
|
90
|
+
const host = consoleHost(request);
|
|
91
|
+
const direct = parsed.protocol === 'http:' && isDirectConsoleHost(request, config, host.hostname);
|
|
92
|
+
if (!originMatchesHost(origin, request) || (!direct && !config.publicOrigins?.includes(origin))) {
|
|
93
|
+
throw new RequestError(403, 'Cross-origin admin request is not allowed');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function originMatchesHost(origin, request) {
|
|
97
|
+
const parsed = new URL(origin);
|
|
98
|
+
return new URL(`${parsed.protocol}//${stringHeader(request.headers.host)}`).origin === parsed.origin;
|
|
99
|
+
}
|
|
100
|
+
export function cookieValue(request, name) {
|
|
101
|
+
const cookie = stringHeader(request.headers.cookie);
|
|
102
|
+
for (const part of cookie.split(';')) {
|
|
103
|
+
const index = part.indexOf('=');
|
|
104
|
+
if (index < 0 || part.slice(0, index).trim() !== name)
|
|
105
|
+
continue;
|
|
106
|
+
return part.slice(index + 1).trim();
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
export function adminCookie(value, ttlMs, config) {
|
|
111
|
+
return [
|
|
112
|
+
`${ADMIN_COOKIE}=${value}`,
|
|
113
|
+
'Path=/',
|
|
114
|
+
'HttpOnly',
|
|
115
|
+
'SameSite=Strict',
|
|
116
|
+
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
|
117
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
118
|
+
].join('; ');
|
|
119
|
+
}
|
|
120
|
+
export function clearAdminCookie(config) {
|
|
121
|
+
return [
|
|
122
|
+
`${ADMIN_COOKIE}=`,
|
|
123
|
+
'Path=/',
|
|
124
|
+
'HttpOnly',
|
|
125
|
+
'SameSite=Strict',
|
|
126
|
+
'Max-Age=0',
|
|
127
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
128
|
+
].join('; ');
|
|
129
|
+
}
|
|
130
|
+
export function isMutating(method) {
|
|
131
|
+
return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
132
|
+
}
|
|
133
|
+
export function remoteKey(request) {
|
|
134
|
+
return request.socket.remoteAddress || 'unknown';
|
|
135
|
+
}
|
|
136
|
+
export function stringHeader(value) {
|
|
137
|
+
return Array.isArray(value) ? value[0] || '' : value || '';
|
|
138
|
+
}
|
|
139
|
+
function safeEqual(left, right) {
|
|
140
|
+
const a = Buffer.from(left);
|
|
141
|
+
const b = Buffer.from(right);
|
|
142
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
143
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { SessionManager } from '../session.js';
|
|
3
|
+
/**
|
|
4
|
+
* Own the lifecycle of one session event connection. Authentication and quota
|
|
5
|
+
* admission happen in the data route; this module is responsible only for
|
|
6
|
+
* replay, authorization revocation, heartbeats, and releasing the slot.
|
|
7
|
+
*/
|
|
8
|
+
export declare function streamSessionEvents(request: IncomingMessage, response: ServerResponse, sessions: SessionManager, sessionId: string, after: string | undefined, release: () => void, watchAuthorization: (close: () => void) => (() => void)): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createSseWriter } from '../sse-writer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Own the lifecycle of one session event connection. Authentication and quota
|
|
4
|
+
* admission happen in the data route; this module is responsible only for
|
|
5
|
+
* replay, authorization revocation, heartbeats, and releasing the slot.
|
|
6
|
+
*/
|
|
7
|
+
export function streamSessionEvents(request, response, sessions, sessionId, after, release, watchAuthorization) {
|
|
8
|
+
response.writeHead(200, {
|
|
9
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
10
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
11
|
+
Connection: 'keep-alive',
|
|
12
|
+
'X-Accel-Buffering': 'no'
|
|
13
|
+
});
|
|
14
|
+
response.flushHeaders?.();
|
|
15
|
+
let unsubscribe = () => { };
|
|
16
|
+
let unwatch = () => { };
|
|
17
|
+
let heartbeat;
|
|
18
|
+
let closed = false;
|
|
19
|
+
const close = () => {
|
|
20
|
+
if (closed)
|
|
21
|
+
return;
|
|
22
|
+
closed = true;
|
|
23
|
+
clearInterval(heartbeat);
|
|
24
|
+
unsubscribe();
|
|
25
|
+
unwatch();
|
|
26
|
+
writer.stop();
|
|
27
|
+
request.off('close', close);
|
|
28
|
+
response.off('close', close);
|
|
29
|
+
request.socket.off('error', close);
|
|
30
|
+
response.destroy();
|
|
31
|
+
release();
|
|
32
|
+
};
|
|
33
|
+
const writer = createSseWriter(response, close);
|
|
34
|
+
const writeEvent = (event) => writer.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
35
|
+
request.once('close', close);
|
|
36
|
+
response.once('close', close);
|
|
37
|
+
request.socket.once('error', close);
|
|
38
|
+
try {
|
|
39
|
+
unwatch = watchAuthorization(close);
|
|
40
|
+
if (closed)
|
|
41
|
+
return;
|
|
42
|
+
const replay = sessions.eventReplay(sessionId, after);
|
|
43
|
+
if (replay.reset) {
|
|
44
|
+
// Keep this control frame bounded even when a snapshot has large
|
|
45
|
+
// output. Reconnect with the advertised cursor and snapshot URL.
|
|
46
|
+
writer.write(`id: ${replay.latestId}\nevent: reset\ndata: ${JSON.stringify({
|
|
47
|
+
...replay.reset,
|
|
48
|
+
earliestId: replay.earliestId,
|
|
49
|
+
latestId: replay.latestId,
|
|
50
|
+
snapshotUrl: `/v1/sessions/${encodeURIComponent(sessionId)}`
|
|
51
|
+
})}\n\n`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
for (const event of replay.events)
|
|
55
|
+
if (!writeEvent(event))
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (closed)
|
|
59
|
+
return;
|
|
60
|
+
unsubscribe = sessions.subscribe(sessionId, writeEvent);
|
|
61
|
+
heartbeat = setInterval(() => writer.write(': heartbeat\n\n'), 15_000);
|
|
62
|
+
heartbeat.unref?.();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
close();
|
|
66
|
+
}
|
|
67
|
+
}
|