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/cli.js CHANGED
@@ -18,22 +18,21 @@ const label = typeof address === 'object' && address
18
18
  console.log(`nexus-agentd listening on ${label}`);
19
19
  for (const url of displayUrls(address))
20
20
  console.log(`WebUI: ${url}`);
21
- if (bootstrap.created) {
21
+ if (bootstrap.created)
22
22
  console.log(`Created config: ${configPath}`);
23
- console.log('Setup required: open the WebUI and create the Console Password.');
24
- if (runtime.config.listen.host !== '127.0.0.1' && runtime.config.listen.host !== '::1') {
25
- console.warn('WARNING: first-run setup is reachable over the network; initialize it immediately on a trusted network.');
26
- }
23
+ if (runtime.controlPlane.setupToken) {
24
+ console.log('Setup required: enter the following one-time setup token in the WebUI, then create the Console Password.');
25
+ console.log(`Setup token: ${runtime.controlPlane.setupToken}`);
26
+ console.warn('Keep this token private. It changes on restart. Use a trusted network or HTTPS for remote setup.');
27
27
  }
28
- let closing = false;
29
- const close = async () => {
30
- if (closing)
31
- return;
32
- closing = true;
33
- await runtime.close();
34
- };
35
- process.once('SIGINT', () => void close().finally(() => process.exit(0)));
36
- process.once('SIGTERM', () => void close().finally(() => process.exit(0)));
28
+ let closing;
29
+ const close = () => closing ??= runtime.close();
30
+ const shutdown = () => void close().then(() => process.exit(0), (error) => {
31
+ console.error(JSON.stringify({ level: 'error', event: 'shutdown_failed', message: error instanceof Error ? error.message : String(error) }));
32
+ process.exit(1);
33
+ });
34
+ process.once('SIGINT', shutdown);
35
+ process.once('SIGTERM', shutdown);
37
36
  function resolveConfigPath(args) {
38
37
  const index = args.indexOf('--config');
39
38
  const value = index >= 0 ? args[index + 1] : process.env.NEXUS_AGENTD_CONFIG;
package/dist/config.js CHANGED
@@ -13,7 +13,7 @@ export async function ensureAgentdConfig(filePath, options = {}) {
13
13
  }
14
14
  const initial = {
15
15
  listen: {
16
- host: options.host?.trim() || '127.0.0.1',
16
+ host: options.host?.trim() || '0.0.0.0',
17
17
  port: bootstrapNumber(options.port, 8787, 1, 65535)
18
18
  },
19
19
  initialized: false,
@@ -56,7 +56,7 @@ export async function loadAgentdConfig(filePath) {
56
56
  if (!isRecord(value))
57
57
  throw new Error('nexus-agentd config root must be an object');
58
58
  const listen = optionalRecord(value.listen, 'listen') || {};
59
- const host = optionalString(listen.host, 'listen.host') || '127.0.0.1';
59
+ const host = optionalString(listen.host, 'listen.host') || '0.0.0.0';
60
60
  const port = integerValue(listen.port, 'listen.port', 8787, 1, 65535);
61
61
  const initialized = optionalBoolean(value.initialized, 'initialized') ?? true;
62
62
  const rawAuthToken = optionalString(value.authToken, 'authToken');
@@ -84,17 +84,45 @@ export async function loadAgentdConfig(filePath) {
84
84
  throw new Error('nexus-agentd workspaceRoots must contain at least one path');
85
85
  }
86
86
  const rawAgents = requiredRecord(value.agents, 'agents');
87
- const agents = {};
87
+ const agents = Object.create(null);
88
88
  for (const [id, input] of Object.entries(rawAgents)) {
89
89
  agents[id] = parseAgent(id, input);
90
90
  }
91
+ const quotas = optionalRecord(value.quotas, 'quotas') || {};
92
+ const history = optionalRecord(value.history, 'history') || {};
93
+ const artifacts = optionalRecord(value.artifacts, 'artifacts') || {};
94
+ const maxSessions = integerValue(value.maxSessions, 'maxSessions', 64, 1, 10_000);
95
+ const maxSseConnections = integerValue(value.maxSseConnections, 'maxSseConnections', 128, 1, 10_000);
91
96
  return {
92
97
  listen: { host, port },
98
+ publicOrigins: value.publicOrigins === undefined ? [] : stringArray(value.publicOrigins, 'publicOrigins').map((origin) => {
99
+ validateHttpUrl(origin, 'publicOrigins');
100
+ if (new URL(origin).origin !== origin)
101
+ throw new Error('publicOrigins must contain exact origins without paths');
102
+ return origin;
103
+ }),
93
104
  initialized,
94
105
  authToken,
95
106
  adminPasswordHash,
96
107
  apiKeys,
97
108
  workspaceRoots,
109
+ quotas: {
110
+ maxSessionsPerKey: integerValue(quotas.maxSessionsPerKey, 'quotas.maxSessionsPerKey', Math.min(16, maxSessions), 1, 10_000),
111
+ maxRunningRunsPerKey: integerValue(quotas.maxRunningRunsPerKey, 'quotas.maxRunningRunsPerKey', Math.min(4, maxSessions), 1, 10_000),
112
+ maxSsePerKey: integerValue(quotas.maxSsePerKey, 'quotas.maxSsePerKey', Math.min(8, maxSseConnections), 1, 10_000),
113
+ maxUploadBytesPerKey: integerValue(quotas.maxUploadBytesPerKey, 'quotas.maxUploadBytesPerKey', 32 * 1024 * 1024, 1024, 1024 * 1024 * 1024)
114
+ },
115
+ history: {
116
+ maxRuns: integerValue(history.maxRuns, 'history.maxRuns', 1000, 1, 100_000),
117
+ retentionDays: integerValue(history.retentionDays, 'history.retentionDays', 30, 1, 3650),
118
+ maxBytes: integerValue(history.maxBytes, 'history.maxBytes', 64 * 1024 * 1024, 1024, 1024 * 1024 * 1024)
119
+ },
120
+ artifacts: {
121
+ retentionDays: integerValue(artifacts.retentionDays, 'artifacts.retentionDays', 30, 1, 3650),
122
+ maxBytes: integerValue(artifacts.maxBytes, 'artifacts.maxBytes', 512 * 1024 * 1024, 1024, 1024 ** 4),
123
+ maxArtifactBytes: integerValue(artifacts.maxArtifactBytes, 'artifacts.maxArtifactBytes', 12 * 1024 * 1024, 1024, 12 * 1024 * 1024),
124
+ maxQueuedBytes: integerValue(artifacts.maxQueuedBytes, 'artifacts.maxQueuedBytes', 64 * 1024 * 1024, 1024, 512 * 1024 * 1024)
125
+ },
98
126
  maxRequestBytes: integerValue(value.maxRequestBytes, 'maxRequestBytes', 1024 * 1024, 1024, 16 * 1024 * 1024),
99
127
  maxAttachmentBytes: integerValue(value.maxAttachmentBytes, 'maxAttachmentBytes', 32 * 1024 * 1024, 1024, 64 * 1024 * 1024),
100
128
  maxEventsPerSession: integerValue(value.maxEventsPerSession, 'maxEventsPerSession', 2048, 64, 100_000),
@@ -103,7 +131,7 @@ export async function loadAgentdConfig(filePath) {
103
131
  cleanupIntervalMs: integerValue(value.cleanupIntervalMs, 'cleanupIntervalMs', 60_000, 5_000, 60 * 60 * 1000),
104
132
  requestTimeoutMs: integerValue(value.requestTimeoutMs, 'requestTimeoutMs', 30_000, 1_000, 5 * 60_000),
105
133
  promptTimeoutMs: integerValue(value.promptTimeoutMs, 'promptTimeoutMs', 30 * 60_000, 10_000, 24 * 60 * 60 * 1000),
106
- maxSessions: integerValue(value.maxSessions, 'maxSessions', 64, 1, 10_000),
134
+ maxSessions,
107
135
  maxSseConnections: integerValue(value.maxSseConnections, 'maxSseConnections', 128, 1, 10_000),
108
136
  maxConnections: integerValue(value.maxConnections, 'maxConnections', 256, 8, 100_000),
109
137
  adminSessionTtlMs: integerValue(value.adminSessionTtlMs, 'adminSessionTtlMs', 12 * 60 * 60 * 1000, 5 * 60_000, 7 * 24 * 60 * 60 * 1000),
@@ -150,6 +178,9 @@ function parseAgent(id, value) {
150
178
  args: input.args === undefined
151
179
  ? undefined
152
180
  : stringArray(input.args, `agents.${id}.args`),
181
+ probeArgs: input.probeArgs === undefined
182
+ ? undefined
183
+ : stringArray(input.probeArgs, `agents.${id}.probeArgs`),
153
184
  inheritEnv: input.inheritEnv === undefined
154
185
  ? undefined
155
186
  : stringArray(input.inheritEnv, `agents.${id}.inheritEnv`),
@@ -157,6 +188,9 @@ function parseAgent(id, value) {
157
188
  permissionPolicy,
158
189
  permissionTimeoutMs: integerValue(input.permissionTimeoutMs, `agents.${id}.permissionTimeoutMs`, 15 * 60 * 1000, 1000, 24 * 60 * 60 * 1000)
159
190
  };
191
+ if (driver === 'stdio' && !result.command) {
192
+ throw new Error(`nexus-agentd agents.${id}.command is required for the stdio driver`);
193
+ }
160
194
  return result;
161
195
  }
162
196
  function parseA2AAgent(id, input) {
@@ -204,7 +238,13 @@ function parseA2AAgent(id, input) {
204
238
  agentUrl,
205
239
  preferredTransport,
206
240
  auth,
207
- timeoutMs: integerValue(input.timeoutMs, `agents.${id}.timeoutMs`, 60_000, 1_000, 30 * 60_000)
241
+ timeoutMs: integerValue(input.timeoutMs, `agents.${id}.timeoutMs`, 60_000, 1_000, 30 * 60_000),
242
+ taskTimeoutMs: input.taskTimeoutMs === undefined
243
+ ? undefined
244
+ : integerValue(input.taskTimeoutMs, `agents.${id}.taskTimeoutMs`, 30 * 60_000, 10_000, 24 * 60 * 60 * 1000),
245
+ streamIdleTimeoutMs: input.streamIdleTimeoutMs === undefined
246
+ ? undefined
247
+ : integerValue(input.streamIdleTimeoutMs, `agents.${id}.streamIdleTimeoutMs`, 60_000, 1_000, 30 * 60_000)
208
248
  };
209
249
  }
210
250
  function parseApiKeys(value) {
@@ -0,0 +1,54 @@
1
+ import type { AgentdProtocol, AgentdDriverKind, PermissionPolicy, A2ATransportPreference, A2AAuthType, AgentdApiKeyScope } from './types.js';
2
+ export interface AgentdAgentUpdate {
3
+ protocol?: AgentdProtocol;
4
+ driver?: AgentdDriverKind;
5
+ name?: string;
6
+ description?: string;
7
+ enabled?: boolean;
8
+ workspace?: string;
9
+ permissionPolicy?: PermissionPolicy;
10
+ permissionTimeoutMs?: number;
11
+ agentCardUrl?: string;
12
+ agentUrl?: string;
13
+ preferredTransport?: A2ATransportPreference;
14
+ authType?: A2AAuthType;
15
+ authValue?: string;
16
+ authHeaderName?: string;
17
+ timeoutMs?: number;
18
+ taskTimeoutMs?: number | null;
19
+ streamIdleTimeoutMs?: number | null;
20
+ }
21
+ export interface AgentdApiKeyUpdate {
22
+ name?: string;
23
+ enabled?: boolean;
24
+ scope?: AgentdApiKeyScope;
25
+ }
26
+ export interface AgentdRuntimeSettingsUpdate {
27
+ sessionTtlMs: number;
28
+ promptTimeoutMs: number;
29
+ cleanupIntervalMs: number;
30
+ }
31
+ export declare class ControlPlaneError extends Error {
32
+ readonly status: number;
33
+ constructor(status: number, message: string);
34
+ }
35
+ export declare function validateRuntimeSettings(update: AgentdRuntimeSettingsUpdate): void;
36
+ export declare function validateIntegerRange(value: number, min: number, max: number, name: string): void;
37
+ export declare function validateUpdate(update: AgentdAgentUpdate): void;
38
+ export declare function validateAgentId(value: string): string;
39
+ export declare function validateOpaqueId(value: string, label: string): string;
40
+ export declare function validatePassword(value: string, confirmation: string): string;
41
+ export declare function validateKeyName(value: string): string;
42
+ export declare function validateApiKeySecret(value: string): string;
43
+ export declare function validateA2ASecret(value: string): string;
44
+ export declare function validateAgentUrl(value: string): string;
45
+ export declare function defaultAgentCardUrl(agentUrl: string | undefined): string | undefined;
46
+ export declare function clampTimeout(value: number | undefined, fallback: number, min: number, max: number): number;
47
+ export declare function preservedAcpAdvanced(previous: Record<string, unknown>): Record<string, unknown>;
48
+ export declare function setOptionalString(target: Record<string, unknown>, key: string, value: string | undefined): void;
49
+ export declare function validHeaderName(value: string): boolean;
50
+ export declare function safeSecretEqual(left: string, right: string): boolean;
51
+ export declare function cleanString(value: unknown): string;
52
+ export declare function recordValue(value: unknown): Record<string, any>;
53
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
54
+ export declare function errorMessage(error: unknown): string;
@@ -0,0 +1,152 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { validateAdminPassword } from './auth.js';
3
+ export class ControlPlaneError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export function validateRuntimeSettings(update) {
11
+ validateIntegerRange(update.sessionTtlMs, 60_000, 30 * 24 * 60 * 60 * 1000, 'sessionTtlMs');
12
+ validateIntegerRange(update.promptTimeoutMs, 10_000, 24 * 60 * 60 * 1000, 'promptTimeoutMs');
13
+ validateIntegerRange(update.cleanupIntervalMs, 5_000, 60 * 60 * 1000, 'cleanupIntervalMs');
14
+ }
15
+ export function validateIntegerRange(value, min, max, name) {
16
+ if (!Number.isInteger(value) || value < min || value > max) {
17
+ throw new ControlPlaneError(400, `${name} must be between ${min} and ${max}`);
18
+ }
19
+ }
20
+ export function validateUpdate(update) {
21
+ if (update.protocol !== undefined && update.protocol !== 'acp' && update.protocol !== 'a2a') {
22
+ throw new ControlPlaneError(400, 'protocol must be acp or a2a');
23
+ }
24
+ if (update.permissionPolicy !== undefined &&
25
+ update.permissionPolicy !== 'ask' &&
26
+ update.permissionPolicy !== 'allow' &&
27
+ update.permissionPolicy !== 'deny') {
28
+ throw new ControlPlaneError(400, 'permissionPolicy must be ask, allow, or deny');
29
+ }
30
+ if (update.preferredTransport !== undefined &&
31
+ !['auto', 'jsonrpc', 'http-json'].includes(update.preferredTransport)) {
32
+ throw new ControlPlaneError(400, 'preferredTransport must be auto, jsonrpc, or http-json');
33
+ }
34
+ }
35
+ export function validateAgentId(value) {
36
+ const id = decodeURIComponent(value).trim().toLowerCase();
37
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) {
38
+ throw new ControlPlaneError(400, 'Invalid Agent ID');
39
+ }
40
+ return id;
41
+ }
42
+ export function validateOpaqueId(value, label) {
43
+ const id = decodeURIComponent(value).trim();
44
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) {
45
+ throw new ControlPlaneError(400, `Invalid ${label} ID`);
46
+ }
47
+ return id;
48
+ }
49
+ export function validatePassword(value, confirmation) {
50
+ if (value !== confirmation) {
51
+ throw new ControlPlaneError(400, 'Console Password confirmation does not match');
52
+ }
53
+ try {
54
+ return validateAdminPassword(value);
55
+ }
56
+ catch (error) {
57
+ throw new ControlPlaneError(400, errorMessage(error));
58
+ }
59
+ }
60
+ export function validateKeyName(value) {
61
+ const name = cleanString(value);
62
+ if (!name)
63
+ throw new ControlPlaneError(400, 'API Key name is required');
64
+ if (name.length > 80)
65
+ throw new ControlPlaneError(400, 'API Key name is too long');
66
+ return name;
67
+ }
68
+ export function validateApiKeySecret(value) {
69
+ const secret = cleanString(value);
70
+ if (secret.length < 16) {
71
+ throw new ControlPlaneError(400, 'Custom API Key must contain at least 16 characters');
72
+ }
73
+ if (Buffer.byteLength(secret, 'utf8') > 512) {
74
+ throw new ControlPlaneError(400, 'Custom API Key must not exceed 512 bytes');
75
+ }
76
+ if (/^env:/i.test(secret) || /[\u0000-\u001f\u007f]/.test(secret)) {
77
+ throw new ControlPlaneError(400, 'Custom API Key contains an unsupported value');
78
+ }
79
+ return secret;
80
+ }
81
+ export function validateA2ASecret(value) {
82
+ const secret = cleanString(value);
83
+ if (!secret)
84
+ return '';
85
+ if (/^env:/i.test(secret) ||
86
+ Buffer.byteLength(secret, 'utf8') > 4096 ||
87
+ /[\u0000\r\n]/.test(secret)) {
88
+ throw new ControlPlaneError(400, 'A2A authentication value is invalid');
89
+ }
90
+ return secret;
91
+ }
92
+ export function validateAgentUrl(value) {
93
+ let url;
94
+ try {
95
+ url = new URL(value);
96
+ }
97
+ catch {
98
+ throw new ControlPlaneError(400, 'A2A Agent Card URL is invalid');
99
+ }
100
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
101
+ throw new ControlPlaneError(400, 'A2A Agent Card URL must use http(s) without credentials or a fragment');
102
+ }
103
+ return url.toString().replace(/\/$/, '');
104
+ }
105
+ export function defaultAgentCardUrl(agentUrl) {
106
+ if (!agentUrl)
107
+ return undefined;
108
+ return new URL('/.well-known/agent-card.json', agentUrl).toString();
109
+ }
110
+ export function clampTimeout(value, fallback, min, max) {
111
+ if (value === undefined)
112
+ return fallback;
113
+ if (!Number.isInteger(value) || value < min || value > max) {
114
+ throw new ControlPlaneError(400, `timeout must be between ${min} and ${max}`);
115
+ }
116
+ return value;
117
+ }
118
+ export function preservedAcpAdvanced(previous) {
119
+ const result = {};
120
+ for (const key of ['command', 'args', 'probeArgs', 'inheritEnv', 'env']) {
121
+ if (previous[key] !== undefined)
122
+ result[key] = structuredClone(previous[key]);
123
+ }
124
+ return result;
125
+ }
126
+ export function setOptionalString(target, key, value) {
127
+ const text = cleanString(value);
128
+ if (text)
129
+ target[key] = text;
130
+ else
131
+ delete target[key];
132
+ }
133
+ export function validHeaderName(value) {
134
+ return /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value);
135
+ }
136
+ export function safeSecretEqual(left, right) {
137
+ const leftBuffer = Buffer.from(left);
138
+ const rightBuffer = Buffer.from(right);
139
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
140
+ }
141
+ export function cleanString(value) {
142
+ return typeof value === 'string' ? value.trim() : '';
143
+ }
144
+ export function recordValue(value) {
145
+ return isRecord(value) ? { ...value } : {};
146
+ }
147
+ export function isRecord(value) {
148
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
149
+ }
150
+ export function errorMessage(error) {
151
+ return error instanceof Error ? error.message : String(error);
152
+ }
@@ -1,39 +1,20 @@
1
+ import { type AgentdAgentUpdate, type AgentdApiKeyUpdate, type AgentdRuntimeSettingsUpdate } from './control-plane-validation.js';
2
+ export { ControlPlaneError, type AgentdAgentUpdate, type AgentdApiKeyUpdate, type AgentdRuntimeSettingsUpdate } from './control-plane-validation.js';
1
3
  import type { SessionManager } from './session.js';
2
- import { type A2AAuthType, type A2ATransportPreference, type AgentdApiKeyPrincipal, type AgentdApiKeyScope, type AgentdApiKeyView, type AgentdConfig, type AgentdControlPlaneView, type AgentdDriverKind, type AgentdProtocol, type PermissionPolicy } from './types.js';
3
- export interface AgentdAgentUpdate {
4
- protocol?: AgentdProtocol;
5
- driver?: AgentdDriverKind;
6
- name?: string;
7
- description?: string;
8
- enabled?: boolean;
9
- workspace?: string;
10
- permissionPolicy?: PermissionPolicy;
11
- permissionTimeoutMs?: number;
12
- agentCardUrl?: string;
13
- agentUrl?: string;
14
- preferredTransport?: A2ATransportPreference;
15
- authType?: A2AAuthType;
16
- authValue?: string;
17
- authHeaderName?: string;
18
- timeoutMs?: number;
19
- }
20
- export interface AgentdApiKeyUpdate {
21
- name?: string;
22
- enabled?: boolean;
23
- scope?: AgentdApiKeyScope;
24
- }
25
- export interface AgentdRuntimeSettingsUpdate {
26
- sessionTtlMs: number;
27
- promptTimeoutMs: number;
28
- cleanupIntervalMs: number;
29
- }
4
+ import { type AgentdApiKeyPrincipal, type AgentdApiKeyScope, type AgentdApiKeyView, type AgentdConfig, type AgentdControlPlaneView } from './types.js';
30
5
  export declare class AgentdControlPlane {
31
6
  private readonly configPath;
32
7
  private config;
33
8
  private readonly sessions;
9
+ /** Process-local proof, delivered only through the local CLI / embedding API. */
10
+ setupToken?: string;
34
11
  private queue;
35
12
  private usageTimer?;
13
+ private readonly keyWatchers;
14
+ /** Observe authorization without updating last-used time or retaining a stale scope. */
15
+ watchApiKey(keyId: string, secret: string, agentId: string, revoked: () => void): () => void;
36
16
  constructor(configPath: string, config: AgentdConfig, sessions: SessionManager);
17
+ verifySetupToken(token: string): boolean;
37
18
  snapshot(): AgentdControlPlaneView;
38
19
  isInitialized(): boolean;
39
20
  needsAdminSetup(): boolean;
@@ -85,7 +66,3 @@ export declare class AgentdControlPlane {
85
66
  private agentView;
86
67
  private exclusive;
87
68
  }
88
- export declare class ControlPlaneError extends Error {
89
- readonly status: number;
90
- constructor(status: number, message: string);
91
- }
@@ -1,7 +1,9 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
1
+ import { ControlPlaneError, validateRuntimeSettings, validateUpdate, validateAgentId, validateOpaqueId, validatePassword, validateKeyName, validateApiKeySecret, validateA2ASecret, validateAgentUrl, defaultAgentCardUrl, clampTimeout, preservedAcpAdvanced, setOptionalString, validHeaderName, safeSecretEqual, cleanString, recordValue, isRecord, errorMessage } from './control-plane-validation.js';
2
+ export { ControlPlaneError } from './control-plane-validation.js';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
2
4
  import { chmod, open, readFile, rename, rm } from 'node:fs/promises';
3
5
  import path from 'node:path';
4
- import { hashAdminPassword, validateAdminPassword, verifyAdminPassword } from './auth.js';
6
+ import { hashAdminPassword, verifyAdminPassword } from './auth.js';
5
7
  import { loadAgentdConfig } from './config.js';
6
8
  import { createDriverRegistry } from './drivers/index.js';
7
9
  import { agentdDriverKinds } from './types.js';
@@ -10,12 +12,33 @@ export class AgentdControlPlane {
10
12
  configPath;
11
13
  config;
12
14
  sessions;
15
+ /** Process-local proof, delivered only through the local CLI / embedding API. */
16
+ setupToken;
13
17
  queue = Promise.resolve();
14
18
  usageTimer;
19
+ keyWatchers = new Set();
20
+ /** Observe authorization without updating last-used time or retaining a stale scope. */
21
+ watchApiKey(keyId, secret, agentId, revoked) {
22
+ const check = () => {
23
+ const key = this.config.apiKeys?.find((item) => item.id === keyId && item.enabled && safeSecretEqual(item.secret, secret));
24
+ if (!key || (!key.scope.allAgents && !key.scope.agentIds.includes(agentId))) {
25
+ this.keyWatchers.delete(check);
26
+ revoked();
27
+ }
28
+ };
29
+ this.keyWatchers.add(check);
30
+ check();
31
+ return () => { this.keyWatchers.delete(check); };
32
+ }
15
33
  constructor(configPath, config, sessions) {
16
34
  this.configPath = configPath;
17
35
  this.config = config;
18
36
  this.sessions = sessions;
37
+ if (!config.adminPasswordHash)
38
+ this.setupToken = randomBytes(32).toString('base64url');
39
+ }
40
+ verifySetupToken(token) {
41
+ return Boolean(this.setupToken && safeSecretEqual(token, this.setupToken));
19
42
  }
20
43
  snapshot() {
21
44
  return {
@@ -54,6 +77,7 @@ export class AgentdControlPlane {
54
77
  if (!Array.isArray(raw.apiKeys))
55
78
  raw.apiKeys = [];
56
79
  await this.persist(raw);
80
+ this.setupToken = undefined;
57
81
  return { initialized: true, adminSetupRequired: false };
58
82
  });
59
83
  }
@@ -250,7 +274,7 @@ export class AgentdControlPlane {
250
274
  const agents = recordValue(raw.agents);
251
275
  const previous = recordValue(agents[id]);
252
276
  let next;
253
- const protocol = update.protocol || 'acp';
277
+ const protocol = update.protocol || previous.protocol || 'acp';
254
278
  if (protocol === 'a2a') {
255
279
  const requestedAgentCardUrl = cleanString(update.agentCardUrl);
256
280
  const requestedAgentUrl = cleanString(update.agentUrl);
@@ -294,8 +318,14 @@ export class AgentdControlPlane {
294
318
  ...(authType !== 'none' ? { value: authValue } : {}),
295
319
  ...(authType === 'header' ? { headerName: authHeaderName } : {})
296
320
  },
297
- timeoutMs: clampTimeout(update.timeoutMs, 60_000, 1000, 30 * 60_000)
321
+ timeoutMs: clampTimeout(update.timeoutMs ?? previous.timeoutMs, 60_000, 1000, 30 * 60_000)
298
322
  };
323
+ for (const field of ['taskTimeoutMs', 'streamIdleTimeoutMs']) {
324
+ const value = update[field] === undefined ? previous[field] : update[field];
325
+ if (value != null) {
326
+ next[field] = clampTimeout(value, 60_000, field === 'taskTimeoutMs' ? 10_000 : 1000, field === 'taskTimeoutMs' ? 24 * 60 * 60_000 : 30 * 60_000);
327
+ }
328
+ }
299
329
  }
300
330
  else {
301
331
  const driver = update.driver || cleanString(previous.driver);
@@ -314,7 +344,7 @@ export class AgentdControlPlane {
314
344
  enabled: update.enabled ?? previous.enabled !== false,
315
345
  workspace,
316
346
  permissionPolicy: update.permissionPolicy || cleanString(previous.permissionPolicy) || 'ask',
317
- permissionTimeoutMs: clampTimeout(update.permissionTimeoutMs, 15 * 60_000, 1000, 24 * 60 * 60 * 1000),
347
+ permissionTimeoutMs: clampTimeout(update.permissionTimeoutMs ?? previous.permissionTimeoutMs, 15 * 60_000, 1000, 24 * 60 * 60 * 1000),
318
348
  ...preservedAcpAdvanced(previous)
319
349
  };
320
350
  if (cleanString(update.name) || cleanString(previous.name)) {
@@ -333,7 +363,7 @@ export class AgentdControlPlane {
333
363
  }
334
364
  const agentIds = Array.from(new Set(input.agentIds.map(validateAgentId)));
335
365
  for (const id of agentIds) {
336
- if (!this.config.agents[id]) {
366
+ if (!Object.hasOwn(this.config.agents, id)) {
337
367
  throw new ControlPlaneError(400, `Configured agent not found: ${id}`);
338
368
  }
339
369
  }
@@ -445,6 +475,8 @@ export class AgentdControlPlane {
445
475
  await chmod(target, 0o600).catch(() => undefined);
446
476
  this.config = nextConfig;
447
477
  this.sessions.reconfigure(nextConfig, workspacePolicy, drivers);
478
+ for (const check of this.keyWatchers)
479
+ check();
448
480
  }
449
481
  finally {
450
482
  if (!moved)
@@ -467,7 +499,9 @@ export class AgentdControlPlane {
467
499
  headerName: config.auth?.headerName,
468
500
  configured: Boolean(config.auth?.value)
469
501
  },
470
- timeoutMs: config.timeoutMs || 60_000
502
+ timeoutMs: config.timeoutMs || 60_000,
503
+ taskTimeoutMs: config.taskTimeoutMs,
504
+ streamIdleTimeoutMs: config.streamIdleTimeoutMs
471
505
  };
472
506
  }
473
507
  return {
@@ -488,153 +522,3 @@ export class AgentdControlPlane {
488
522
  return result;
489
523
  }
490
524
  }
491
- export class ControlPlaneError extends Error {
492
- status;
493
- constructor(status, message) {
494
- super(message);
495
- this.status = status;
496
- }
497
- }
498
- function validateRuntimeSettings(update) {
499
- validateIntegerRange(update.sessionTtlMs, 60_000, 30 * 24 * 60 * 60 * 1000, 'sessionTtlMs');
500
- validateIntegerRange(update.promptTimeoutMs, 10_000, 24 * 60 * 60 * 1000, 'promptTimeoutMs');
501
- validateIntegerRange(update.cleanupIntervalMs, 5_000, 60 * 60 * 1000, 'cleanupIntervalMs');
502
- }
503
- function validateIntegerRange(value, min, max, name) {
504
- if (!Number.isInteger(value) || value < min || value > max) {
505
- throw new ControlPlaneError(400, `${name} must be between ${min} and ${max}`);
506
- }
507
- }
508
- function validateUpdate(update) {
509
- if (update.protocol !== undefined && update.protocol !== 'acp' && update.protocol !== 'a2a') {
510
- throw new ControlPlaneError(400, 'protocol must be acp or a2a');
511
- }
512
- if (update.permissionPolicy !== undefined &&
513
- update.permissionPolicy !== 'ask' &&
514
- update.permissionPolicy !== 'allow' &&
515
- update.permissionPolicy !== 'deny') {
516
- throw new ControlPlaneError(400, 'permissionPolicy must be ask, allow, or deny');
517
- }
518
- if (update.preferredTransport !== undefined &&
519
- !['auto', 'jsonrpc', 'http-json'].includes(update.preferredTransport)) {
520
- throw new ControlPlaneError(400, 'preferredTransport must be auto, jsonrpc, or http-json');
521
- }
522
- }
523
- function validateAgentId(value) {
524
- const id = decodeURIComponent(value).trim().toLowerCase();
525
- if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) {
526
- throw new ControlPlaneError(400, 'Invalid Agent ID');
527
- }
528
- return id;
529
- }
530
- function validateOpaqueId(value, label) {
531
- const id = decodeURIComponent(value).trim();
532
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) {
533
- throw new ControlPlaneError(400, `Invalid ${label} ID`);
534
- }
535
- return id;
536
- }
537
- function validatePassword(value, confirmation) {
538
- if (value !== confirmation) {
539
- throw new ControlPlaneError(400, 'Console Password confirmation does not match');
540
- }
541
- try {
542
- return validateAdminPassword(value);
543
- }
544
- catch (error) {
545
- throw new ControlPlaneError(400, errorMessage(error));
546
- }
547
- }
548
- function validateKeyName(value) {
549
- const name = cleanString(value);
550
- if (!name)
551
- throw new ControlPlaneError(400, 'API Key name is required');
552
- if (name.length > 80)
553
- throw new ControlPlaneError(400, 'API Key name is too long');
554
- return name;
555
- }
556
- function validateApiKeySecret(value) {
557
- const secret = cleanString(value);
558
- if (secret.length < 16) {
559
- throw new ControlPlaneError(400, 'Custom API Key must contain at least 16 characters');
560
- }
561
- if (Buffer.byteLength(secret, 'utf8') > 512) {
562
- throw new ControlPlaneError(400, 'Custom API Key must not exceed 512 bytes');
563
- }
564
- if (/^env:/i.test(secret) || /[\u0000-\u001f\u007f]/.test(secret)) {
565
- throw new ControlPlaneError(400, 'Custom API Key contains an unsupported value');
566
- }
567
- return secret;
568
- }
569
- function validateA2ASecret(value) {
570
- const secret = cleanString(value);
571
- if (!secret)
572
- return '';
573
- if (/^env:/i.test(secret) ||
574
- Buffer.byteLength(secret, 'utf8') > 4096 ||
575
- /[\u0000\r\n]/.test(secret)) {
576
- throw new ControlPlaneError(400, 'A2A authentication value is invalid');
577
- }
578
- return secret;
579
- }
580
- function validateAgentUrl(value) {
581
- let url;
582
- try {
583
- url = new URL(value);
584
- }
585
- catch {
586
- throw new ControlPlaneError(400, 'A2A Agent Card URL is invalid');
587
- }
588
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
589
- throw new ControlPlaneError(400, 'A2A Agent Card URL must use http(s) without credentials or a fragment');
590
- }
591
- return url.toString().replace(/\/$/, '');
592
- }
593
- function defaultAgentCardUrl(agentUrl) {
594
- if (!agentUrl)
595
- return undefined;
596
- return new URL('/.well-known/agent-card.json', agentUrl).toString();
597
- }
598
- function clampTimeout(value, fallback, min, max) {
599
- if (value === undefined)
600
- return fallback;
601
- if (!Number.isInteger(value) || value < min || value > max) {
602
- throw new ControlPlaneError(400, `timeout must be between ${min} and ${max}`);
603
- }
604
- return value;
605
- }
606
- function preservedAcpAdvanced(previous) {
607
- const result = {};
608
- for (const key of ['command', 'args', 'inheritEnv', 'env']) {
609
- if (previous[key] !== undefined)
610
- result[key] = structuredClone(previous[key]);
611
- }
612
- return result;
613
- }
614
- function setOptionalString(target, key, value) {
615
- const text = cleanString(value);
616
- if (text)
617
- target[key] = text;
618
- else
619
- delete target[key];
620
- }
621
- function validHeaderName(value) {
622
- return /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value);
623
- }
624
- function safeSecretEqual(left, right) {
625
- const leftBuffer = Buffer.from(left);
626
- const rightBuffer = Buffer.from(right);
627
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
628
- }
629
- function cleanString(value) {
630
- return typeof value === 'string' ? value.trim() : '';
631
- }
632
- function recordValue(value) {
633
- return isRecord(value) ? { ...value } : {};
634
- }
635
- function isRecord(value) {
636
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
637
- }
638
- function errorMessage(error) {
639
- return error instanceof Error ? error.message : String(error);
640
- }