nexus-agentd 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +103 -14
  3. package/dist/a2a/runtime.d.ts +4 -1
  4. package/dist/a2a/runtime.js +213 -10
  5. package/dist/acp/runtime.js +13 -6
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/config.js +45 -5
  10. package/dist/control-plane-validation.d.ts +54 -0
  11. package/dist/control-plane-validation.js +152 -0
  12. package/dist/control-plane.d.ts +9 -32
  13. package/dist/control-plane.js +41 -157
  14. package/dist/drivers/index.js +12 -0
  15. package/dist/drivers/pi.js +1 -1
  16. package/dist/drivers/stdio.d.ts +4 -1
  17. package/dist/drivers/stdio.js +135 -19
  18. package/dist/events.d.ts +9 -0
  19. package/dist/events.js +17 -4
  20. package/dist/index.js +10 -1
  21. package/dist/process-tree.js +15 -6
  22. package/dist/run-store.d.ts +52 -7
  23. package/dist/run-store.js +178 -22
  24. package/dist/server/auth-policy.d.ts +27 -0
  25. package/dist/server/auth-policy.js +143 -0
  26. package/dist/server/connection.d.ts +8 -0
  27. package/dist/server/connection.js +67 -0
  28. package/dist/server/diagnostics.d.ts +40 -0
  29. package/dist/server/diagnostics.js +130 -0
  30. package/dist/server/metrics.d.ts +51 -0
  31. package/dist/server/metrics.js +56 -0
  32. package/dist/server/quota.d.ts +59 -0
  33. package/dist/server/quota.js +176 -0
  34. package/dist/server/validation.d.ts +29 -0
  35. package/dist/server/validation.js +221 -0
  36. package/dist/server.d.ts +3 -0
  37. package/dist/server.js +682 -418
  38. package/dist/session/admin-runs.d.ts +73 -0
  39. package/dist/session/admin-runs.js +297 -0
  40. package/dist/session/admission.d.ts +32 -0
  41. package/dist/session/admission.js +62 -0
  42. package/dist/session/readiness.d.ts +33 -0
  43. package/dist/session/readiness.js +174 -0
  44. package/dist/session.d.ts +58 -6
  45. package/dist/session.js +244 -109
  46. package/dist/sse-writer.d.ts +7 -0
  47. package/dist/sse-writer.js +60 -0
  48. package/dist/types.d.ts +44 -2
  49. package/dist/types.js +1 -0
  50. package/dist/webui/app/sources.js +16 -8
  51. package/dist/webui/index.js +11 -11
  52. package/dist/webui/markup.js +2 -0
  53. package/dist/webui/styles.js +46 -1
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +53 -34
  56. package/package.json +3 -2
package/dist/run-store.js CHANGED
@@ -1,6 +1,8 @@
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';
4
6
  const STATES = new Set([
5
7
  'created',
6
8
  'running',
@@ -14,17 +16,43 @@ const MAX_OUTPUT_CHARS = 256 * 1024;
14
16
  const MAX_SUMMARY_CHARS = 600;
15
17
  export class RunStore {
16
18
  filePath;
17
- maxRuns;
18
- maxTaskChars;
19
19
  runs = new Map();
20
20
  initialized = false;
21
21
  dirty = false;
22
22
  persistTimer;
23
23
  writeQueue = Promise.resolve();
24
- constructor(filePath, maxRuns = 1000, maxTaskChars = 1024 * 1024) {
24
+ artifactStore;
25
+ maxRuns;
26
+ maxTaskChars;
27
+ historyRetentionDays;
28
+ historyMaxBytes;
29
+ now;
30
+ lastWriteMs = 0;
31
+ writeFailures = 0;
32
+ historyBytes = 0;
33
+ constructor(filePath, maxRunsOrOptions = 1000, maxTaskCharsOrOptions = undefined, suppliedOptions = {}) {
25
34
  this.filePath = filePath;
26
- this.maxRuns = maxRuns;
27
- this.maxTaskChars = maxTaskChars;
35
+ const positionalOptions = typeof maxRunsOrOptions === 'object' ? maxRunsOrOptions : { ...suppliedOptions };
36
+ if (typeof maxTaskCharsOrOptions === 'object') {
37
+ Object.assign(positionalOptions, maxTaskCharsOrOptions);
38
+ }
39
+ this.maxRuns = positiveInteger(typeof maxRunsOrOptions === 'number'
40
+ ? maxRunsOrOptions
41
+ : positionalOptions.maxRuns, 1000);
42
+ this.maxTaskChars = positiveInteger(typeof maxTaskCharsOrOptions === 'number'
43
+ ? maxTaskCharsOrOptions
44
+ : maxTaskCharsOrOptions?.maxTaskChars ?? positionalOptions.maxTaskChars, 1024 * 1024);
45
+ this.historyRetentionDays = nonNegativeNumber(positionalOptions.historyRetentionDays, undefined);
46
+ this.historyMaxBytes = nonNegativeInteger(positionalOptions.historyMaxBytes, undefined);
47
+ this.now = positionalOptions.now || Date.now;
48
+ const artifactRoot = positionalOptions.rootDir ||
49
+ positionalOptions.artifactRootDir ||
50
+ artifactStorePathForRunStore(filePath);
51
+ this.artifactStore = new ArtifactStore(artifactRoot, {
52
+ ...positionalOptions,
53
+ onViewsChanged: (runId, views) => this.applyArtifactViews(runId, views),
54
+ isRunActive: (runId) => isActive(this.runs.get(runId)?.state)
55
+ });
28
56
  }
29
57
  async init() {
30
58
  if (this.initialized)
@@ -52,8 +80,7 @@ export class RunStore {
52
80
  }
53
81
  }
54
82
  }
55
- this.initialized = true;
56
- const now = Date.now();
83
+ const now = this.now();
57
84
  for (const [id, run] of this.runs) {
58
85
  if (!isActive(run.state))
59
86
  continue;
@@ -68,6 +95,16 @@ export class RunStore {
68
95
  });
69
96
  this.dirty = true;
70
97
  }
98
+ await this.artifactStore.init();
99
+ for (const [id, run] of this.runs) {
100
+ const views = this.artifactStore.restoreRun(id, run.artifacts);
101
+ if (JSON.stringify(run.artifacts) !== JSON.stringify(views)) {
102
+ run.artifacts = views;
103
+ run.artifactCount = views.length;
104
+ this.dirty = true;
105
+ }
106
+ }
107
+ this.initialized = true;
71
108
  this.prune();
72
109
  if (!(await fileExists(this.filePath)))
73
110
  this.dirty = true;
@@ -76,7 +113,7 @@ export class RunStore {
76
113
  }
77
114
  create(input) {
78
115
  this.assertInitialized();
79
- const now = Date.now();
116
+ const now = this.now();
80
117
  const taskTruncated = input.task.length > this.maxTaskChars;
81
118
  const run = {
82
119
  id: randomUUID(),
@@ -89,6 +126,7 @@ export class RunStore {
89
126
  ownerKeyId: input.ownerKeyId,
90
127
  task: taskTruncated ? input.task.slice(0, this.maxTaskChars) : input.task,
91
128
  taskTruncated: taskTruncated || undefined,
129
+ retryOfRunId: input.retryOfRunId,
92
130
  inputAttachmentCount: input.inputAttachmentCount || undefined,
93
131
  state: 'running',
94
132
  progress: { phase: '已接收', message: '任务已交给智能体' },
@@ -109,10 +147,15 @@ export class RunStore {
109
147
  const output = patch.output === undefined
110
148
  ? current.output
111
149
  : clipTail(patch.output, MAX_OUTPUT_CHARS);
112
- const updatedAt = patch.updatedAt ?? Date.now();
150
+ const updatedAt = patch.updatedAt ?? this.now();
151
+ const normalizedPatch = structuredClone(patch);
152
+ if (patch.artifacts !== undefined) {
153
+ normalizedPatch.artifacts = this.artifactStore.mergeViews(id, patch.artifacts, false);
154
+ normalizedPatch.artifactCount = normalizedPatch.artifacts.length;
155
+ }
113
156
  const next = {
114
157
  ...current,
115
- ...structuredClone(patch),
158
+ ...normalizedPatch,
116
159
  output,
117
160
  resultSummary: output ? summarize(output) : current.resultSummary,
118
161
  updatedAt
@@ -120,11 +163,54 @@ export class RunStore {
120
163
  if (patch.endedAt !== undefined) {
121
164
  next.durationMs = Math.max(0, patch.endedAt - next.startedAt);
122
165
  }
123
- this.runs.delete(id);
166
+ // Preserve creation order as the stable tie-breaker for same-ms tasks.
124
167
  this.runs.set(id, next);
125
168
  this.changed();
126
169
  return publicDetail(next);
127
170
  }
171
+ /** Queue payload snapshots without blocking the protocol sink. */
172
+ recordArtifacts(runId, artifacts) {
173
+ this.assertInitialized();
174
+ if (!this.runs.has(runId))
175
+ return undefined;
176
+ return this.artifactStore.recordArtifacts(runId, artifacts);
177
+ }
178
+ artifactViews(runId) {
179
+ this.assertInitialized();
180
+ if (!this.runs.has(runId))
181
+ return undefined;
182
+ return this.artifactStore.views(runId);
183
+ }
184
+ async readArtifact(runId, artifactId) {
185
+ this.assertInitialized();
186
+ if (!this.runs.has(runId))
187
+ return undefined;
188
+ return this.artifactStore.readArtifact(runId, artifactId);
189
+ }
190
+ getOwnerKeyId(runId) {
191
+ this.assertInitialized();
192
+ return this.runs.get(runId)?.ownerKeyId;
193
+ }
194
+ metrics() {
195
+ const artifactStoreStats = this.artifactStore.stats();
196
+ return {
197
+ // This is the last successfully persisted JSON size. A dirty store
198
+ // reports that size until its queued write completes, avoiding a
199
+ // full-history serialization on every metrics poll.
200
+ historyBytes: this.historyBytes,
201
+ retainedRuns: this.runs.size,
202
+ pendingWrites: (this.dirty ? 1 : 0) + artifactStoreStats.pendingWrites,
203
+ lastWriteMs: this.lastWriteMs,
204
+ writeFailures: this.writeFailures,
205
+ artifactStoreStats
206
+ };
207
+ }
208
+ stats() {
209
+ return this.metrics();
210
+ }
211
+ setRetryOfRunId(runId, retryOfRunId) {
212
+ return this.update(runId, { retryOfRunId });
213
+ }
128
214
  get(id) {
129
215
  this.assertInitialized();
130
216
  const run = this.runs.get(id);
@@ -134,8 +220,10 @@ export class RunStore {
134
220
  this.assertInitialized();
135
221
  const needle = String(query.query || '').trim().toLowerCase();
136
222
  const limit = Math.min(200, Math.max(1, Number(query.limit) || 50));
223
+ const offset = Math.max(0, Math.floor(Number(query.offset) || 0));
137
224
  const matched = Array.from(this.runs.values())
138
225
  .reverse()
226
+ .sort((left, right) => right.startedAt - left.startedAt)
139
227
  .filter((run) => !query.agentId || run.agentId === query.agentId)
140
228
  .filter((run) => !query.sessionId || run.sessionId === query.sessionId)
141
229
  .filter((run) => !query.state || run.state === query.state)
@@ -153,7 +241,7 @@ export class RunStore {
153
241
  if (run.state === 'failed')
154
242
  stats.failed++;
155
243
  }
156
- const runs = matched.slice(0, limit).map(publicView);
244
+ const runs = matched.slice(offset, offset + limit).map(publicView);
157
245
  return { runs, total, stats };
158
246
  }
159
247
  async flush() {
@@ -161,6 +249,11 @@ export class RunStore {
161
249
  if (this.persistTimer)
162
250
  clearTimeout(this.persistTimer);
163
251
  this.persistTimer = undefined;
252
+ if (this.dirty)
253
+ await this.persist();
254
+ await this.artifactStore.flush();
255
+ // Successful artifact commits update run metadata via the callback
256
+ // above, so persist those storage facts as well.
164
257
  if (this.dirty)
165
258
  await this.persist();
166
259
  await this.writeQueue;
@@ -174,6 +267,7 @@ export class RunStore {
174
267
  !Array.isArray(parsed.runs)) {
175
268
  throw new InvalidRunFileError('Invalid Agent Nexus run history file');
176
269
  }
270
+ this.historyBytes = Buffer.byteLength(raw);
177
271
  for (const value of parsed.runs) {
178
272
  const run = normalizeRun(value);
179
273
  if (run)
@@ -198,12 +292,39 @@ export class RunStore {
198
292
  this.persistTimer.unref?.();
199
293
  }
200
294
  prune() {
201
- while (this.runs.size > this.maxRuns) {
202
- const terminal = Array.from(this.runs.entries()).find(([, run]) => !isActive(run.state));
295
+ const removeOldestTerminal = (predicate) => {
296
+ const terminal = Array.from(this.runs.entries())
297
+ .filter(([, run]) => !isActive(run.state) && predicate(run))
298
+ .sort(([, left], [, right]) => left.updatedAt - right.updatedAt)[0];
203
299
  if (!terminal)
204
- break;
300
+ return false;
205
301
  this.runs.delete(terminal[0]);
302
+ this.artifactStore.removeRun(terminal[0]);
206
303
  this.dirty = true;
304
+ return true;
305
+ };
306
+ while (this.runs.size > this.maxRuns) {
307
+ if (!removeOldestTerminal(() => true))
308
+ break;
309
+ }
310
+ if (this.historyRetentionDays !== undefined) {
311
+ const cutoff = this.now() - this.historyRetentionDays * 24 * 60 * 60 * 1000;
312
+ while (removeOldestTerminal((run) => (run.endedAt ?? run.updatedAt) <= cutoff)) { }
313
+ }
314
+ if (this.historyMaxBytes !== undefined) {
315
+ const historyBytes = () => Buffer.byteLength(JSON.stringify(Array.from(this.runs.values())));
316
+ while (historyBytes() > this.historyMaxBytes) {
317
+ if (!removeOldestTerminal(() => true)) {
318
+ console.error(JSON.stringify({
319
+ level: 'warn',
320
+ event: 'run_history_budget_exceeded',
321
+ bytes: historyBytes(),
322
+ budget: this.historyMaxBytes,
323
+ activeRunsProtected: Array.from(this.runs.values()).filter((run) => isActive(run.state)).length
324
+ }));
325
+ break;
326
+ }
327
+ }
207
328
  }
208
329
  }
209
330
  persist() {
@@ -216,13 +337,22 @@ export class RunStore {
216
337
  };
217
338
  const serialized = `${JSON.stringify(payload, null, 2)}\n`;
218
339
  const write = async () => {
340
+ const startedAt = this.now();
219
341
  const temp = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
220
- await writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600 });
221
- await rename(temp, this.filePath);
222
- await chmod(this.filePath, 0o600).catch(() => undefined);
342
+ try {
343
+ await writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600 });
344
+ await rename(temp, this.filePath);
345
+ await chmod(this.filePath, 0o600).catch(() => undefined);
346
+ this.historyBytes = Buffer.byteLength(serialized);
347
+ this.lastWriteMs = Math.max(0, this.now() - startedAt);
348
+ }
349
+ finally {
350
+ await unlink(temp).catch(() => undefined);
351
+ }
223
352
  };
224
353
  const next = this.writeQueue.then(write, write).catch((error) => {
225
354
  this.dirty = true;
355
+ this.writeFailures++;
226
356
  throw error;
227
357
  });
228
358
  this.writeQueue = next.catch(() => undefined);
@@ -232,6 +362,19 @@ export class RunStore {
232
362
  if (!this.initialized)
233
363
  throw new Error('RunStore is not initialized');
234
364
  }
365
+ applyArtifactViews(runId, views) {
366
+ const run = this.runs.get(runId);
367
+ if (!run)
368
+ return;
369
+ run.artifacts = structuredClone(views);
370
+ run.artifactCount = run.artifacts.length;
371
+ run.updatedAt = this.now();
372
+ if (!this.initialized) {
373
+ this.dirty = true;
374
+ return;
375
+ }
376
+ this.changed();
377
+ }
235
378
  }
236
379
  export function runStorePathForConfig(configPath) {
237
380
  const absolute = path.resolve(configPath);
@@ -263,13 +406,26 @@ function normalizeRun(value) {
263
406
  artifactCount: Number.isFinite(run.artifactCount) ? run.artifactCount : 0
264
407
  };
265
408
  }
409
+ function positiveInteger(value, fallback) {
410
+ return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : fallback;
411
+ }
412
+ function nonNegativeInteger(value, fallback) {
413
+ return Number.isFinite(value) && Number(value) >= 0 ? Math.floor(Number(value)) : fallback;
414
+ }
415
+ function nonNegativeNumber(value, fallback) {
416
+ return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : fallback;
417
+ }
266
418
  function publicView(run) {
267
- const { ownerKeyId: _ownerKeyId, output: _output, artifacts: _artifacts, ...view } = run;
268
- return structuredClone(view);
419
+ const { ownerKeyId: _ownerKeyId, task, output: _output, artifacts: _artifacts, ...view } = run;
420
+ return structuredClone({ ...view, taskPreview: task.slice(0, 240),
421
+ // Remote errors/progress can be large too; full values remain in details.
422
+ error: view.error?.slice(0, 600),
423
+ progress: { ...view.progress, phase: view.progress.phase.slice(0, 120), message: view.progress.message?.slice(0, 600) }
424
+ });
269
425
  }
270
426
  function publicDetail(run) {
271
427
  const { ownerKeyId: _ownerKeyId, ...detail } = run;
272
- return structuredClone(detail);
428
+ return structuredClone({ ...detail, taskPreview: run.task.slice(0, 240) });
273
429
  }
274
430
  function summarize(value) {
275
431
  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
+ }
@@ -0,0 +1,40 @@
1
+ import type { AgentdAgentConfig, AgentdSessionView } from '../types.js';
2
+ type SessionCreator = {
3
+ create(agentId: string, workspace: string | undefined, ownerKeyId: string): Promise<AgentdSessionView>;
4
+ close(id: string): Promise<AgentdSessionView>;
5
+ };
6
+ export interface DiagnosticStage {
7
+ name: 'validate' | 'handshake' | 'close';
8
+ status: 'passed' | 'failed' | 'skipped';
9
+ durationMs: number;
10
+ message?: string;
11
+ }
12
+ export interface DiagnosticResult {
13
+ ok: boolean;
14
+ status: 'passed' | 'failed' | 'busy';
15
+ durationMs: number;
16
+ stages: DiagnosticStage[];
17
+ message?: string;
18
+ }
19
+ export declare class DiagnosticConcurrencyError extends Error {
20
+ readonly status = 429;
21
+ readonly retryAfterMs = 1000;
22
+ constructor();
23
+ }
24
+ /**
25
+ * Run the real SessionManager startup path as a diagnostic. SessionManager's
26
+ * `create` performs ACP initialize + session/new (or A2A card/transport
27
+ * setup), and `close` releases the process and the session slot. No prompt
28
+ * is sent here by design.
29
+ */
30
+ export declare class DiagnosticRunner {
31
+ private readonly maxConcurrent;
32
+ private active;
33
+ constructor(maxConcurrent?: number);
34
+ run(sessions: SessionCreator, agentId: string, workspace: string | undefined, agentConfig?: AgentdAgentConfig): Promise<DiagnosticResult>;
35
+ count(): number;
36
+ private result;
37
+ }
38
+ /** Keep command lines, absolute paths and credential-shaped values out of UI. */
39
+ export declare function safeDiagnosticMessage(error: unknown): string;
40
+ export {};