nexus-agentd 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
package/dist/control-plane.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
}
|
|
@@ -188,7 +212,17 @@ export class AgentdControlPlane {
|
|
|
188
212
|
if (!workspaceRoots.length) {
|
|
189
213
|
throw new ControlPlaneError(400, 'workspaceRoots must contain at least one path');
|
|
190
214
|
}
|
|
191
|
-
await WorkspacePolicy.create(workspaceRoots);
|
|
215
|
+
const policy = await WorkspacePolicy.create(workspaceRoots);
|
|
216
|
+
for (const [id, agent] of Object.entries(this.config.agents)) {
|
|
217
|
+
if (agent.protocol === 'a2a' || !agent.workspace)
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
await policy.resolve(agent.workspace);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw new ControlPlaneError(409, `Workspace change would exclude agent: ${id}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
192
226
|
const raw = await this.readRawConfig();
|
|
193
227
|
raw.workspaceRoots = workspaceRoots;
|
|
194
228
|
await this.persist(raw);
|
|
@@ -240,7 +274,7 @@ export class AgentdControlPlane {
|
|
|
240
274
|
const agents = recordValue(raw.agents);
|
|
241
275
|
const previous = recordValue(agents[id]);
|
|
242
276
|
let next;
|
|
243
|
-
const protocol = update.protocol || 'acp';
|
|
277
|
+
const protocol = update.protocol || previous.protocol || 'acp';
|
|
244
278
|
if (protocol === 'a2a') {
|
|
245
279
|
const requestedAgentCardUrl = cleanString(update.agentCardUrl);
|
|
246
280
|
const requestedAgentUrl = cleanString(update.agentUrl);
|
|
@@ -284,8 +318,14 @@ export class AgentdControlPlane {
|
|
|
284
318
|
...(authType !== 'none' ? { value: authValue } : {}),
|
|
285
319
|
...(authType === 'header' ? { headerName: authHeaderName } : {})
|
|
286
320
|
},
|
|
287
|
-
timeoutMs: clampTimeout(update.timeoutMs, 60_000, 1000, 30 * 60_000)
|
|
321
|
+
timeoutMs: clampTimeout(update.timeoutMs ?? previous.timeoutMs, 60_000, 1000, 30 * 60_000)
|
|
288
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
|
+
}
|
|
289
329
|
}
|
|
290
330
|
else {
|
|
291
331
|
const driver = update.driver || cleanString(previous.driver);
|
|
@@ -304,7 +344,7 @@ export class AgentdControlPlane {
|
|
|
304
344
|
enabled: update.enabled ?? previous.enabled !== false,
|
|
305
345
|
workspace,
|
|
306
346
|
permissionPolicy: update.permissionPolicy || cleanString(previous.permissionPolicy) || 'ask',
|
|
307
|
-
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),
|
|
308
348
|
...preservedAcpAdvanced(previous)
|
|
309
349
|
};
|
|
310
350
|
if (cleanString(update.name) || cleanString(previous.name)) {
|
|
@@ -323,7 +363,7 @@ export class AgentdControlPlane {
|
|
|
323
363
|
}
|
|
324
364
|
const agentIds = Array.from(new Set(input.agentIds.map(validateAgentId)));
|
|
325
365
|
for (const id of agentIds) {
|
|
326
|
-
if (!this.config.agents
|
|
366
|
+
if (!Object.hasOwn(this.config.agents, id)) {
|
|
327
367
|
throw new ControlPlaneError(400, `Configured agent not found: ${id}`);
|
|
328
368
|
}
|
|
329
369
|
}
|
|
@@ -435,6 +475,8 @@ export class AgentdControlPlane {
|
|
|
435
475
|
await chmod(target, 0o600).catch(() => undefined);
|
|
436
476
|
this.config = nextConfig;
|
|
437
477
|
this.sessions.reconfigure(nextConfig, workspacePolicy, drivers);
|
|
478
|
+
for (const check of this.keyWatchers)
|
|
479
|
+
check();
|
|
438
480
|
}
|
|
439
481
|
finally {
|
|
440
482
|
if (!moved)
|
|
@@ -457,7 +499,9 @@ export class AgentdControlPlane {
|
|
|
457
499
|
headerName: config.auth?.headerName,
|
|
458
500
|
configured: Boolean(config.auth?.value)
|
|
459
501
|
},
|
|
460
|
-
timeoutMs: config.timeoutMs || 60_000
|
|
502
|
+
timeoutMs: config.timeoutMs || 60_000,
|
|
503
|
+
taskTimeoutMs: config.taskTimeoutMs,
|
|
504
|
+
streamIdleTimeoutMs: config.streamIdleTimeoutMs
|
|
461
505
|
};
|
|
462
506
|
}
|
|
463
507
|
return {
|
|
@@ -478,153 +522,3 @@ export class AgentdControlPlane {
|
|
|
478
522
|
return result;
|
|
479
523
|
}
|
|
480
524
|
}
|
|
481
|
-
export class ControlPlaneError extends Error {
|
|
482
|
-
status;
|
|
483
|
-
constructor(status, message) {
|
|
484
|
-
super(message);
|
|
485
|
-
this.status = status;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
function validateRuntimeSettings(update) {
|
|
489
|
-
validateIntegerRange(update.sessionTtlMs, 60_000, 30 * 24 * 60 * 60 * 1000, 'sessionTtlMs');
|
|
490
|
-
validateIntegerRange(update.promptTimeoutMs, 10_000, 24 * 60 * 60 * 1000, 'promptTimeoutMs');
|
|
491
|
-
validateIntegerRange(update.cleanupIntervalMs, 5_000, 60 * 60 * 1000, 'cleanupIntervalMs');
|
|
492
|
-
}
|
|
493
|
-
function validateIntegerRange(value, min, max, name) {
|
|
494
|
-
if (!Number.isInteger(value) || value < min || value > max) {
|
|
495
|
-
throw new ControlPlaneError(400, `${name} must be between ${min} and ${max}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
function validateUpdate(update) {
|
|
499
|
-
if (update.protocol !== undefined && update.protocol !== 'acp' && update.protocol !== 'a2a') {
|
|
500
|
-
throw new ControlPlaneError(400, 'protocol must be acp or a2a');
|
|
501
|
-
}
|
|
502
|
-
if (update.permissionPolicy !== undefined &&
|
|
503
|
-
update.permissionPolicy !== 'ask' &&
|
|
504
|
-
update.permissionPolicy !== 'allow' &&
|
|
505
|
-
update.permissionPolicy !== 'deny') {
|
|
506
|
-
throw new ControlPlaneError(400, 'permissionPolicy must be ask, allow, or deny');
|
|
507
|
-
}
|
|
508
|
-
if (update.preferredTransport !== undefined &&
|
|
509
|
-
!['auto', 'jsonrpc', 'http-json'].includes(update.preferredTransport)) {
|
|
510
|
-
throw new ControlPlaneError(400, 'preferredTransport must be auto, jsonrpc, or http-json');
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
function validateAgentId(value) {
|
|
514
|
-
const id = decodeURIComponent(value).trim().toLowerCase();
|
|
515
|
-
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) {
|
|
516
|
-
throw new ControlPlaneError(400, 'Invalid Agent ID');
|
|
517
|
-
}
|
|
518
|
-
return id;
|
|
519
|
-
}
|
|
520
|
-
function validateOpaqueId(value, label) {
|
|
521
|
-
const id = decodeURIComponent(value).trim();
|
|
522
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) {
|
|
523
|
-
throw new ControlPlaneError(400, `Invalid ${label} ID`);
|
|
524
|
-
}
|
|
525
|
-
return id;
|
|
526
|
-
}
|
|
527
|
-
function validatePassword(value, confirmation) {
|
|
528
|
-
if (value !== confirmation) {
|
|
529
|
-
throw new ControlPlaneError(400, 'Console Password confirmation does not match');
|
|
530
|
-
}
|
|
531
|
-
try {
|
|
532
|
-
return validateAdminPassword(value);
|
|
533
|
-
}
|
|
534
|
-
catch (error) {
|
|
535
|
-
throw new ControlPlaneError(400, errorMessage(error));
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
function validateKeyName(value) {
|
|
539
|
-
const name = cleanString(value);
|
|
540
|
-
if (!name)
|
|
541
|
-
throw new ControlPlaneError(400, 'API Key name is required');
|
|
542
|
-
if (name.length > 80)
|
|
543
|
-
throw new ControlPlaneError(400, 'API Key name is too long');
|
|
544
|
-
return name;
|
|
545
|
-
}
|
|
546
|
-
function validateApiKeySecret(value) {
|
|
547
|
-
const secret = cleanString(value);
|
|
548
|
-
if (secret.length < 16) {
|
|
549
|
-
throw new ControlPlaneError(400, 'Custom API Key must contain at least 16 characters');
|
|
550
|
-
}
|
|
551
|
-
if (Buffer.byteLength(secret, 'utf8') > 512) {
|
|
552
|
-
throw new ControlPlaneError(400, 'Custom API Key must not exceed 512 bytes');
|
|
553
|
-
}
|
|
554
|
-
if (/^env:/i.test(secret) || /[\u0000-\u001f\u007f]/.test(secret)) {
|
|
555
|
-
throw new ControlPlaneError(400, 'Custom API Key contains an unsupported value');
|
|
556
|
-
}
|
|
557
|
-
return secret;
|
|
558
|
-
}
|
|
559
|
-
function validateA2ASecret(value) {
|
|
560
|
-
const secret = cleanString(value);
|
|
561
|
-
if (!secret)
|
|
562
|
-
return '';
|
|
563
|
-
if (/^env:/i.test(secret) ||
|
|
564
|
-
Buffer.byteLength(secret, 'utf8') > 4096 ||
|
|
565
|
-
/[\u0000\r\n]/.test(secret)) {
|
|
566
|
-
throw new ControlPlaneError(400, 'A2A authentication value is invalid');
|
|
567
|
-
}
|
|
568
|
-
return secret;
|
|
569
|
-
}
|
|
570
|
-
function validateAgentUrl(value) {
|
|
571
|
-
let url;
|
|
572
|
-
try {
|
|
573
|
-
url = new URL(value);
|
|
574
|
-
}
|
|
575
|
-
catch {
|
|
576
|
-
throw new ControlPlaneError(400, 'A2A Agent Card URL is invalid');
|
|
577
|
-
}
|
|
578
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
579
|
-
throw new ControlPlaneError(400, 'A2A Agent Card URL must use http(s) without credentials or a fragment');
|
|
580
|
-
}
|
|
581
|
-
return url.toString().replace(/\/$/, '');
|
|
582
|
-
}
|
|
583
|
-
function defaultAgentCardUrl(agentUrl) {
|
|
584
|
-
if (!agentUrl)
|
|
585
|
-
return undefined;
|
|
586
|
-
return new URL('/.well-known/agent-card.json', agentUrl).toString();
|
|
587
|
-
}
|
|
588
|
-
function clampTimeout(value, fallback, min, max) {
|
|
589
|
-
if (value === undefined)
|
|
590
|
-
return fallback;
|
|
591
|
-
if (!Number.isInteger(value) || value < min || value > max) {
|
|
592
|
-
throw new ControlPlaneError(400, `timeout must be between ${min} and ${max}`);
|
|
593
|
-
}
|
|
594
|
-
return value;
|
|
595
|
-
}
|
|
596
|
-
function preservedAcpAdvanced(previous) {
|
|
597
|
-
const result = {};
|
|
598
|
-
for (const key of ['command', 'args', 'inheritEnv', 'env']) {
|
|
599
|
-
if (previous[key] !== undefined)
|
|
600
|
-
result[key] = structuredClone(previous[key]);
|
|
601
|
-
}
|
|
602
|
-
return result;
|
|
603
|
-
}
|
|
604
|
-
function setOptionalString(target, key, value) {
|
|
605
|
-
const text = cleanString(value);
|
|
606
|
-
if (text)
|
|
607
|
-
target[key] = text;
|
|
608
|
-
else
|
|
609
|
-
delete target[key];
|
|
610
|
-
}
|
|
611
|
-
function validHeaderName(value) {
|
|
612
|
-
return /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value);
|
|
613
|
-
}
|
|
614
|
-
function safeSecretEqual(left, right) {
|
|
615
|
-
const leftBuffer = Buffer.from(left);
|
|
616
|
-
const rightBuffer = Buffer.from(right);
|
|
617
|
-
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
618
|
-
}
|
|
619
|
-
function cleanString(value) {
|
|
620
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
621
|
-
}
|
|
622
|
-
function recordValue(value) {
|
|
623
|
-
return isRecord(value) ? { ...value } : {};
|
|
624
|
-
}
|
|
625
|
-
function isRecord(value) {
|
|
626
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
627
|
-
}
|
|
628
|
-
function errorMessage(error) {
|
|
629
|
-
return error instanceof Error ? error.message : String(error);
|
|
630
|
-
}
|
package/dist/drivers/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHermesDriver } from './hermes.js';
|
|
|
4
4
|
import { createOpenCodeDriver } from './opencode.js';
|
|
5
5
|
import { createOpenClawDriver } from './openclaw.js';
|
|
6
6
|
import { createPiDriver } from './pi.js';
|
|
7
|
+
import { createStdioAcpDriver } from './stdio.js';
|
|
7
8
|
export function createDriverRegistry(config) {
|
|
8
9
|
const drivers = new Map();
|
|
9
10
|
for (const [id, driver] of Object.entries(config.agents)) {
|
|
@@ -12,6 +13,17 @@ export function createDriverRegistry(config) {
|
|
|
12
13
|
if (driver.enabled === false)
|
|
13
14
|
continue;
|
|
14
15
|
switch (driver.driver) {
|
|
16
|
+
case 'stdio':
|
|
17
|
+
if (!driver.command)
|
|
18
|
+
break;
|
|
19
|
+
drivers.set(id, createStdioAcpDriver(id, driver, {
|
|
20
|
+
name: driver.name || id,
|
|
21
|
+
description: driver.description || 'Custom ACP agent over stdio',
|
|
22
|
+
command: driver.command,
|
|
23
|
+
args: driver.args || [],
|
|
24
|
+
probeArgs: driver.probeArgs
|
|
25
|
+
}));
|
|
26
|
+
break;
|
|
15
27
|
case 'opencode':
|
|
16
28
|
drivers.set(id, createOpenCodeDriver(id, driver));
|
|
17
29
|
break;
|
package/dist/drivers/pi.js
CHANGED
|
@@ -15,7 +15,7 @@ export function createPiDriver(id, config) {
|
|
|
15
15
|
return adapter;
|
|
16
16
|
const command = driver.env.PI_ACP_PI_COMMAND || 'pi';
|
|
17
17
|
try {
|
|
18
|
-
const version = await probeCommand(command, ['--version'], driver.env, 'Pi runtime');
|
|
18
|
+
const version = await probeCommand(command, ['--version'], driver.env, 'Pi runtime', { ownsProcessGroup: process.platform !== 'win32' });
|
|
19
19
|
return { ...adapter, version };
|
|
20
20
|
}
|
|
21
21
|
catch (error) {
|
package/dist/drivers/stdio.d.ts
CHANGED
|
@@ -10,4 +10,7 @@ export interface StdioAcpDriverDefaults {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function createStdioAcpDriver(id: string, config: AgentdDriverConfig, defaults: StdioAcpDriverDefaults): AgentDriver;
|
|
12
12
|
export declare function buildEnvironment(config: AgentdDriverConfig, defaults?: Record<string, string>): NodeJS.ProcessEnv;
|
|
13
|
-
export declare function probeCommand(command: string, args: string[], env: NodeJS.ProcessEnv, label: string
|
|
13
|
+
export declare function probeCommand(command: string, args: string[], env: NodeJS.ProcessEnv, label: string, options?: {
|
|
14
|
+
ownsProcessGroup?: boolean;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}): Promise<string>;
|
package/dist/drivers/stdio.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
2
5
|
import { resolveSecret } from '../config.js';
|
|
6
|
+
import { terminateProcessTree } from '../process-tree.js';
|
|
3
7
|
const BASE_ENV_KEYS = [
|
|
4
8
|
'PATH',
|
|
5
9
|
'HOME',
|
|
@@ -11,7 +15,17 @@ const BASE_ENV_KEYS = [
|
|
|
11
15
|
'TMPDIR',
|
|
12
16
|
'TEMP',
|
|
13
17
|
'LANG',
|
|
14
|
-
'LC_ALL'
|
|
18
|
+
'LC_ALL',
|
|
19
|
+
// Node's Windows process creation and a number of ACP launchers require
|
|
20
|
+
// these values even when the command itself is an absolute path.
|
|
21
|
+
'SystemRoot',
|
|
22
|
+
'ComSpec',
|
|
23
|
+
'USERPROFILE',
|
|
24
|
+
'APPDATA',
|
|
25
|
+
'LOCALAPPDATA',
|
|
26
|
+
'ProgramFiles',
|
|
27
|
+
'ProgramFiles(x86)',
|
|
28
|
+
'PATHEXT'
|
|
15
29
|
];
|
|
16
30
|
export function createStdioAcpDriver(id, config, defaults) {
|
|
17
31
|
const command = config.command || defaults.command;
|
|
@@ -31,7 +45,7 @@ export function createStdioAcpDriver(id, config, defaults) {
|
|
|
31
45
|
ownsProcessGroup: process.platform !== 'win32',
|
|
32
46
|
async probe() {
|
|
33
47
|
try {
|
|
34
|
-
const version = await probeCommand(command, defaults.probeArgs || ['--version'], env, name);
|
|
48
|
+
const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, { ownsProcessGroup: process.platform !== 'win32' });
|
|
35
49
|
return {
|
|
36
50
|
id,
|
|
37
51
|
name,
|
|
@@ -53,7 +67,7 @@ export function createStdioAcpDriver(id, config, defaults) {
|
|
|
53
67
|
}
|
|
54
68
|
},
|
|
55
69
|
spawn(workspace) {
|
|
56
|
-
return
|
|
70
|
+
return spawnCommand(command, args, {
|
|
57
71
|
cwd: workspace,
|
|
58
72
|
env,
|
|
59
73
|
detached: process.platform !== 'win32',
|
|
@@ -75,42 +89,144 @@ export function buildEnvironment(config, defaults = {}) {
|
|
|
75
89
|
}
|
|
76
90
|
return result;
|
|
77
91
|
}
|
|
78
|
-
export function probeCommand(command, args, env, label) {
|
|
92
|
+
export function probeCommand(command, args, env, label, options = {}) {
|
|
79
93
|
return new Promise((resolve, reject) => {
|
|
80
|
-
const child =
|
|
94
|
+
const child = spawnCommand(command, args, {
|
|
81
95
|
env,
|
|
82
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
96
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
97
|
+
detached: options.ownsProcessGroup ?? process.platform !== 'win32',
|
|
98
|
+
windowsHide: true
|
|
83
99
|
});
|
|
84
100
|
let output = '';
|
|
85
101
|
let settled = false;
|
|
102
|
+
let finishing = false;
|
|
103
|
+
let timedOut = false;
|
|
104
|
+
let timer;
|
|
86
105
|
const finish = (error) => {
|
|
87
106
|
if (settled)
|
|
88
107
|
return;
|
|
89
108
|
settled = true;
|
|
90
|
-
|
|
109
|
+
if (timer)
|
|
110
|
+
clearTimeout(timer);
|
|
91
111
|
if (error)
|
|
92
112
|
reject(error);
|
|
93
113
|
else
|
|
94
114
|
resolve(output.trim().split(/\r?\n/)[0] || 'unknown');
|
|
95
115
|
};
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
const ownsProcessGroup = options.ownsProcessGroup ?? process.platform !== 'win32';
|
|
117
|
+
const cleanup = () => terminateProcessTree(child, {
|
|
118
|
+
ownsProcessGroup,
|
|
119
|
+
graceMs: 250
|
|
120
|
+
});
|
|
121
|
+
const cleanupFailure = (error) => error instanceof Error ? error.message : String(error);
|
|
122
|
+
const settleExit = (code, signal) => {
|
|
123
|
+
if (settled || finishing || timedOut)
|
|
124
|
+
return;
|
|
125
|
+
finishing = true;
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
// A successful wrapper can leave a child in its process group.
|
|
129
|
+
// Clean the group before exposing probe success/failure.
|
|
130
|
+
void cleanup().then(() => {
|
|
131
|
+
if (timedOut || settled)
|
|
132
|
+
return;
|
|
133
|
+
finishing = false;
|
|
134
|
+
if (code === 0)
|
|
135
|
+
finish();
|
|
136
|
+
else {
|
|
137
|
+
finish(new Error(output.trim() ||
|
|
138
|
+
`${label} probe exited with ${signal || code || 'unknown'}`));
|
|
139
|
+
}
|
|
140
|
+
}, (error) => {
|
|
141
|
+
if (settled)
|
|
142
|
+
return;
|
|
143
|
+
finishing = false;
|
|
144
|
+
finish(new Error(`${label} probe cleanup failed: ${cleanupFailure(error)}`));
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
if (settled || finishing || timedOut)
|
|
149
|
+
return;
|
|
150
|
+
timedOut = true;
|
|
151
|
+
finishing = true;
|
|
152
|
+
if (timer)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
// Probe commands are allowed to be wrappers that launch another
|
|
155
|
+
// process. Wait for tree termination before rejecting so a timed
|
|
156
|
+
// out health check cannot leave an agent running in the background.
|
|
157
|
+
void cleanup().then(() => finish(new Error(`${label} probe timed out`)), (error) => finish(new Error(`${label} probe timed out; cleanup failed: ${cleanupFailure(error)}`)));
|
|
158
|
+
}, options.timeoutMs ?? 5000);
|
|
159
|
+
timer.unref?.();
|
|
100
160
|
child.stdout.on('data', (chunk) => {
|
|
101
161
|
output = `${output}${String(chunk)}`.slice(-8192);
|
|
102
162
|
});
|
|
103
163
|
child.stderr.on('data', (chunk) => {
|
|
104
164
|
output = `${output}${String(chunk)}`.slice(-8192);
|
|
105
165
|
});
|
|
106
|
-
child.once('error', (error) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
166
|
+
child.once('error', (error) => {
|
|
167
|
+
if (settled || finishing || timedOut)
|
|
168
|
+
return;
|
|
169
|
+
finishing = true;
|
|
170
|
+
if (timer)
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
void cleanup().then(() => finish(error), (cleanupError) => finish(new Error(`${label} probe failed (${error.message}); cleanup failed: ${cleanupFailure(cleanupError)}`)));
|
|
114
173
|
});
|
|
174
|
+
child.once('exit', (code, signal) => settleExit(code, signal));
|
|
115
175
|
});
|
|
116
176
|
}
|
|
177
|
+
function needsWindowsShell(command) {
|
|
178
|
+
return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
|
|
179
|
+
}
|
|
180
|
+
function spawnCommand(command, args, options) {
|
|
181
|
+
const resolvedCommand = resolveWindowsCommand(command, options.env, options.cwd);
|
|
182
|
+
if (!needsWindowsShell(resolvedCommand)) {
|
|
183
|
+
return spawn(resolvedCommand, args, options);
|
|
184
|
+
}
|
|
185
|
+
// cmd.exe's /S /C parser needs an outer pair around the complete command
|
|
186
|
+
// when the batch path itself is quoted; otherwise it treats the first
|
|
187
|
+
// argument as part of the executable path.
|
|
188
|
+
const commandLine = `"${[quoteWindowsArg(resolvedCommand), ...args.map(quoteWindowsArg)].join(' ')}"`;
|
|
189
|
+
return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
|
|
190
|
+
...options,
|
|
191
|
+
windowsVerbatimArguments: true,
|
|
192
|
+
shell: false
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function resolveWindowsCommand(command, env, cwd) {
|
|
196
|
+
if (process.platform !== 'win32')
|
|
197
|
+
return command;
|
|
198
|
+
const pathValue = env?.PATH || process.env.PATH || '';
|
|
199
|
+
const directory = cwd instanceof URL ? fileURLToPath(cwd) : cwd || process.cwd();
|
|
200
|
+
const pathEntries = pathValue.split(path.delimiter).filter(Boolean).map((entry) => entry.replace(/^"|"$/g, ''));
|
|
201
|
+
const pathExtValue = env?.PATHEXT || process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD';
|
|
202
|
+
const extensions = pathExtValue
|
|
203
|
+
.split(';')
|
|
204
|
+
.map((extension) => extension.trim())
|
|
205
|
+
.filter((extension) => /^\.(?:com|exe|bat|cmd)$/i.test(extension));
|
|
206
|
+
// npm installs a POSIX shim without an extension beside its .cmd shim.
|
|
207
|
+
// That file is not a Windows executable; selecting it makes real npm
|
|
208
|
+
// commands fail even though a runnable .cmd exists in the same directory.
|
|
209
|
+
const names = /\.(?:com|exe|bat|cmd)$/i.test(command)
|
|
210
|
+
? [command] : extensions.map((extension) => `${command}${extension}`);
|
|
211
|
+
const candidates = commandHasPath(command)
|
|
212
|
+
? names.map((name) => path.resolve(directory, name))
|
|
213
|
+
: [directory, ...pathEntries].flatMap((entry) => names.map((name) => path.resolve(directory, entry, name)));
|
|
214
|
+
const match = candidates.find(isRegularFile);
|
|
215
|
+
return match || command;
|
|
216
|
+
}
|
|
217
|
+
function commandHasPath(command) {
|
|
218
|
+
return command.includes('\\') || command.includes('/') || path.win32.isAbsolute(command);
|
|
219
|
+
}
|
|
220
|
+
function isRegularFile(file) {
|
|
221
|
+
try {
|
|
222
|
+
return existsSync(file) && statSync(file).isFile();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function quoteWindowsArg(value) {
|
|
229
|
+
if (!/[\s"&()^|<>]/.test(value))
|
|
230
|
+
return value;
|
|
231
|
+
return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/g, '$1$1')}"`;
|
|
232
|
+
}
|
package/dist/events.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ export declare class SessionEventLog {
|
|
|
8
8
|
constructor(sessionId: string, maxEvents: number);
|
|
9
9
|
append(type: AgentdEventType, data?: unknown): AgentdEvent;
|
|
10
10
|
after(id?: string): AgentdEvent[];
|
|
11
|
+
replay(id?: string): {
|
|
12
|
+
earliestId: string;
|
|
13
|
+
latestId: string;
|
|
14
|
+
reset: {
|
|
15
|
+
reason: string;
|
|
16
|
+
requestedAfter: string | null;
|
|
17
|
+
} | undefined;
|
|
18
|
+
events: AgentdEvent[];
|
|
19
|
+
};
|
|
11
20
|
subscribe(listener: (event: AgentdEvent) => void): () => boolean;
|
|
12
21
|
get lastId(): string | undefined;
|
|
13
22
|
}
|
package/dist/events.js
CHANGED
|
@@ -36,10 +36,23 @@ export class SessionEventLog {
|
|
|
36
36
|
return event;
|
|
37
37
|
}
|
|
38
38
|
after(id) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
return this.replay(id).events;
|
|
40
|
+
}
|
|
41
|
+
replay(id) {
|
|
42
|
+
const sequence = id === undefined ? 0 : Number(id);
|
|
43
|
+
const earliest = Number(this.events[0]?.id || this.sequence + 1);
|
|
44
|
+
const reason = id !== undefined && (!/^\d+$/.test(id) || !Number.isSafeInteger(sequence))
|
|
45
|
+
? 'invalid'
|
|
46
|
+
: sequence > this.sequence ? 'ahead'
|
|
47
|
+
: sequence < earliest - 1 ? 'expired' : undefined;
|
|
48
|
+
return {
|
|
49
|
+
earliestId: this.events[0]?.id ?? null,
|
|
50
|
+
latestId: String(this.sequence),
|
|
51
|
+
reset: reason ? { reason, requestedAfter: id ?? null } : undefined,
|
|
52
|
+
events: reason ? [] : this.events
|
|
53
|
+
.filter((event) => Number(event.id) > sequence)
|
|
54
|
+
.map((event) => structuredClone(event))
|
|
55
|
+
};
|
|
43
56
|
}
|
|
44
57
|
subscribe(listener) {
|
|
45
58
|
this.listeners.add(listener);
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare function startAgentd(configPath: string): Promise<{
|
|
|
8
8
|
sessions: SessionManager;
|
|
9
9
|
runStore: RunStore;
|
|
10
10
|
controlPlane: AgentdControlPlane;
|
|
11
|
-
close()
|
|
11
|
+
close: () => Promise<void>;
|
|
12
12
|
}>;
|
|
13
13
|
export declare function closeServer(server: Server, graceMs?: number): Promise<void>;
|
|
14
14
|
export * from './config.js';
|
package/dist/index.js
CHANGED
|
@@ -9,24 +9,51 @@ import { WorkspacePolicy } from './workspace.js';
|
|
|
9
9
|
export async function startAgentd(configPath) {
|
|
10
10
|
const absoluteConfigPath = path.resolve(configPath);
|
|
11
11
|
const config = await loadAgentdConfig(absoluteConfigPath);
|
|
12
|
-
const runStore = new RunStore(runStorePathForConfig(absoluteConfigPath),
|
|
12
|
+
const runStore = new RunStore(runStorePathForConfig(absoluteConfigPath), {
|
|
13
|
+
maxRuns: config.history?.maxRuns ?? 1000,
|
|
14
|
+
maxTaskChars: config.maxRequestBytes,
|
|
15
|
+
historyRetentionDays: config.history?.retentionDays ?? 30,
|
|
16
|
+
historyMaxBytes: config.history?.maxBytes ?? 64 * 1024 * 1024,
|
|
17
|
+
retentionDays: config.artifacts?.retentionDays ?? 30,
|
|
18
|
+
maxTotalBytes: config.artifacts?.maxBytes ?? 512 * 1024 * 1024,
|
|
19
|
+
maxArtifactBytes: config.artifacts?.maxArtifactBytes ?? 12 * 1024 * 1024,
|
|
20
|
+
maxQueuedBytes: config.artifacts?.maxQueuedBytes ?? 64 * 1024 * 1024
|
|
21
|
+
});
|
|
13
22
|
await runStore.init();
|
|
14
23
|
const workspacePolicy = await WorkspacePolicy.create(config.workspaceRoots);
|
|
15
24
|
const sessions = new SessionManager(config, workspacePolicy, createDriverRegistry(config), runStore);
|
|
16
25
|
sessions.startCleanup();
|
|
17
26
|
const controlPlane = new AgentdControlPlane(absoluteConfigPath, config, sessions);
|
|
18
27
|
const server = createAgentdServer(config, sessions, controlPlane);
|
|
19
|
-
|
|
28
|
+
let closing;
|
|
29
|
+
const close = () => closing ??= (async () => {
|
|
30
|
+
const results = await Promise.allSettled([closeServer(server), sessions.shutdown()]);
|
|
31
|
+
await runStore.flush();
|
|
32
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
33
|
+
if (failed)
|
|
34
|
+
throw failed.reason;
|
|
35
|
+
})();
|
|
36
|
+
try {
|
|
37
|
+
await listen(server, config.listen.port, config.listen.host);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await sessions.shutdown();
|
|
41
|
+
await runStore.flush();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
server.on('error', (error) => {
|
|
45
|
+
console.error(JSON.stringify({ level: 'error', event: 'server_failed', message: error.message }));
|
|
46
|
+
void close().catch((closeError) => {
|
|
47
|
+
console.error(JSON.stringify({ level: 'error', event: 'server_shutdown_failed', message: String(closeError) }));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
20
50
|
return {
|
|
21
51
|
config,
|
|
22
52
|
server,
|
|
23
53
|
sessions,
|
|
24
54
|
runStore,
|
|
25
55
|
controlPlane,
|
|
26
|
-
|
|
27
|
-
await Promise.all([closeServer(server), sessions.shutdown()]);
|
|
28
|
-
await runStore.flush();
|
|
29
|
-
}
|
|
56
|
+
close
|
|
30
57
|
};
|
|
31
58
|
}
|
|
32
59
|
function listen(server, port, host) {
|