nexus-agentd 0.1.0 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +351 -179
- package/dist/a2a/runtime.d.ts +30 -0
- package/dist/a2a/runtime.js +374 -0
- package/dist/acp/runtime.d.ts +12 -3
- package/dist/acp/runtime.js +248 -17
- package/dist/artifact-store.d.ts +41 -0
- package/dist/artifact-store.js +340 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +77 -0
- package/dist/cli.js +58 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +281 -53
- package/dist/control-plane.d.ts +91 -0
- package/dist/control-plane.js +629 -0
- package/dist/drivers/hermes.d.ts +2 -0
- package/dist/drivers/hermes.js +10 -0
- package/dist/drivers/index.js +6 -0
- package/dist/events.js +13 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +15 -3
- package/dist/run-store.d.ts +49 -0
- package/dist/run-store.js +291 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.js +911 -41
- package/dist/session-contract.d.ts +16 -4
- package/dist/session.d.ts +74 -8
- package/dist/session.js +433 -38
- package/dist/types.d.ts +193 -6
- package/dist/types.js +2 -1
- package/dist/webui/app.d.ts +1 -0
- package/dist/webui/app.js +1072 -0
- package/dist/webui/icons.d.ts +9 -0
- package/dist/webui/icons.js +10 -0
- package/dist/webui/index.d.ts +3 -0
- package/dist/webui/index.js +32 -0
- package/dist/webui/markup.d.ts +1 -0
- package/dist/webui/markup.js +68 -0
- package/dist/webui/styles.d.ts +1 -0
- package/dist/webui/styles.js +269 -0
- package/dist/workspace-files.d.ts +48 -0
- package/dist/workspace-files.js +269 -0
- package/nexus-agentd.example.json +30 -27
- package/package.json +6 -6
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, mkdir, open, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
export class ArtifactStoreError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class GatewayArtifactStore {
|
|
15
|
+
directory;
|
|
16
|
+
ttlMs;
|
|
17
|
+
maxBytes;
|
|
18
|
+
maxStorageBytes;
|
|
19
|
+
maxFiles;
|
|
20
|
+
maxConcurrentPublishes;
|
|
21
|
+
byCacheKey = new Map();
|
|
22
|
+
cleanupTimer;
|
|
23
|
+
capacityQueue = Promise.resolve();
|
|
24
|
+
activePublishes = 0;
|
|
25
|
+
reservedBytes = 0;
|
|
26
|
+
reservedFiles = 0;
|
|
27
|
+
constructor(directory, ttlMs, maxBytes, maxStorageBytes = Math.max(maxBytes, 4 * 1024 * 1024 * 1024), maxFiles = 4096, maxConcurrentPublishes = 4) {
|
|
28
|
+
this.directory = directory;
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.maxBytes = maxBytes;
|
|
31
|
+
this.maxStorageBytes = maxStorageBytes;
|
|
32
|
+
this.maxFiles = maxFiles;
|
|
33
|
+
this.maxConcurrentPublishes = maxConcurrentPublishes;
|
|
34
|
+
}
|
|
35
|
+
startCleanup(intervalMs = Math.min(this.ttlMs, 60 * 60 * 1000)) {
|
|
36
|
+
if (this.cleanupTimer)
|
|
37
|
+
return;
|
|
38
|
+
void this.cleanup().catch((error) => {
|
|
39
|
+
console.error(`[nexus-agentd] initial artifact cleanup failed: ${errorMessage(error)}`);
|
|
40
|
+
});
|
|
41
|
+
this.cleanupTimer = setInterval(() => {
|
|
42
|
+
void this.cleanup().catch((error) => {
|
|
43
|
+
console.error(`[nexus-agentd] artifact cleanup failed: ${errorMessage(error)}`);
|
|
44
|
+
});
|
|
45
|
+
}, Math.max(60_000, intervalMs));
|
|
46
|
+
this.cleanupTimer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
stopCleanup() {
|
|
49
|
+
if (this.cleanupTimer)
|
|
50
|
+
clearInterval(this.cleanupTimer);
|
|
51
|
+
this.cleanupTimer = undefined;
|
|
52
|
+
}
|
|
53
|
+
async publishFile(filePath, preferredName, mediaType) {
|
|
54
|
+
const handle = await open(filePath, 'r');
|
|
55
|
+
try {
|
|
56
|
+
return await this.publishHandle(handle, preferredName || path.basename(filePath), mediaType);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async publishHandle(handle, preferredName, mediaType) {
|
|
63
|
+
const details = await handle.stat();
|
|
64
|
+
if (!details.isFile())
|
|
65
|
+
throw new ArtifactStoreError(400, 'Only regular files can be published');
|
|
66
|
+
if (details.size > this.maxBytes)
|
|
67
|
+
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
68
|
+
return this.publishStream(handle.createReadStream({ autoClose: false }), preferredName, mediaType, details.size);
|
|
69
|
+
}
|
|
70
|
+
async publishBytes(bytes, preferredName, mediaType, cacheKey) {
|
|
71
|
+
if (bytes.byteLength > this.maxBytes) {
|
|
72
|
+
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
73
|
+
}
|
|
74
|
+
const { Readable } = await import('node:stream');
|
|
75
|
+
return this.publishStream(Readable.from([bytes]), preferredName, mediaType, bytes.byteLength, cacheKey);
|
|
76
|
+
}
|
|
77
|
+
async publishStream(input, preferredName, mediaType, declaredSize, cacheKey) {
|
|
78
|
+
if (declaredSize !== undefined && declaredSize > this.maxBytes) {
|
|
79
|
+
throw new ArtifactStoreError(413, 'File exceeds the publish size limit');
|
|
80
|
+
}
|
|
81
|
+
await this.ensureDirectory();
|
|
82
|
+
const releaseCapacity = await this.reserveCapacity(declaredSize ?? this.maxBytes);
|
|
83
|
+
const name = safeFilename(preferredName);
|
|
84
|
+
const temporary = path.join(this.directory, `.upload-${randomUUID()}`);
|
|
85
|
+
const hash = createHash('sha256');
|
|
86
|
+
let size = 0;
|
|
87
|
+
const meter = new Transform({
|
|
88
|
+
transform: (chunk, _encoding, callback) => {
|
|
89
|
+
size += chunk.length;
|
|
90
|
+
if (size > this.maxBytes) {
|
|
91
|
+
callback(new ArtifactStoreError(413, 'File exceeds the publish size limit'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
hash.update(chunk);
|
|
95
|
+
callback(null, chunk);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await pipeline(input, meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }));
|
|
100
|
+
const sha256 = hash.digest('hex');
|
|
101
|
+
const cacheId = cacheKey ? `${cacheKey}\0${sha256}` : undefined;
|
|
102
|
+
const existing = cacheId ? this.byCacheKey.get(cacheId) : undefined;
|
|
103
|
+
if (existing && (await this.isAvailable(existing))) {
|
|
104
|
+
await rm(temporary, { force: true });
|
|
105
|
+
return publicView(existing);
|
|
106
|
+
}
|
|
107
|
+
const id = randomBytes(32).toString('hex');
|
|
108
|
+
const expiresAt = Date.now() + this.ttlMs;
|
|
109
|
+
const filePath = path.join(this.directory, storedFilename(id, expiresAt, name));
|
|
110
|
+
await rename(temporary, filePath);
|
|
111
|
+
const stored = {
|
|
112
|
+
id,
|
|
113
|
+
name,
|
|
114
|
+
url: artifactUrl(id, expiresAt, name),
|
|
115
|
+
size,
|
|
116
|
+
mediaType: mediaType || mediaTypeForName(name),
|
|
117
|
+
sha256,
|
|
118
|
+
expiresAt,
|
|
119
|
+
filePath
|
|
120
|
+
};
|
|
121
|
+
if (cacheId)
|
|
122
|
+
this.byCacheKey.set(cacheId, stored);
|
|
123
|
+
return publicView(stored);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
releaseCapacity();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async open(id, expiresInput, requestedName) {
|
|
134
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
135
|
+
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
136
|
+
const expiresAt = Number(expiresInput);
|
|
137
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
|
|
138
|
+
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
139
|
+
}
|
|
140
|
+
const name = safeFilename(requestedName);
|
|
141
|
+
if (name !== requestedName)
|
|
142
|
+
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
143
|
+
const filePath = path.join(this.directory, storedFilename(id, expiresAt, name));
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
handle = await open(filePath, 'r');
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') {
|
|
150
|
+
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
let details;
|
|
155
|
+
try {
|
|
156
|
+
details = await handle.stat();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
await handle.close();
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
if (!details.isFile()) {
|
|
163
|
+
await handle.close();
|
|
164
|
+
throw new ArtifactStoreError(404, 'Artifact not found');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
name,
|
|
169
|
+
url: artifactUrl(id, expiresAt, name),
|
|
170
|
+
size: details.size,
|
|
171
|
+
mediaType: mediaTypeForName(name),
|
|
172
|
+
sha256: '',
|
|
173
|
+
expiresAt,
|
|
174
|
+
filePath,
|
|
175
|
+
handle
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async cleanup() {
|
|
179
|
+
await this.ensureDirectory();
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const names = await readdir(this.directory);
|
|
182
|
+
await Promise.all(names.map(async (name) => {
|
|
183
|
+
const filePath = path.join(this.directory, name);
|
|
184
|
+
try {
|
|
185
|
+
const parsed = parseStoredFilename(name);
|
|
186
|
+
if (parsed?.expiresAt !== undefined && parsed.expiresAt <= now) {
|
|
187
|
+
await rm(filePath, { force: true });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (name.startsWith('.upload-')) {
|
|
191
|
+
const details = await stat(filePath);
|
|
192
|
+
if (details.mtimeMs + Math.max(this.ttlMs, 60 * 60 * 1000) <= now) {
|
|
193
|
+
await rm(filePath, { force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code !== 'ENOENT')
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
for (const [cacheId, artifact] of this.byCacheKey) {
|
|
203
|
+
if (artifact.expiresAt <= now)
|
|
204
|
+
this.byCacheKey.delete(cacheId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async ensureDirectory() {
|
|
208
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
209
|
+
if (process.platform !== 'win32')
|
|
210
|
+
await chmod(this.directory, 0o700);
|
|
211
|
+
}
|
|
212
|
+
async isAvailable(artifact) {
|
|
213
|
+
if (artifact.expiresAt <= Date.now())
|
|
214
|
+
return false;
|
|
215
|
+
try {
|
|
216
|
+
const details = await stat(artifact.filePath);
|
|
217
|
+
return details.isFile();
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code === 'ENOENT')
|
|
221
|
+
return false;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async reserveCapacity(expectedBytes) {
|
|
226
|
+
let unlock;
|
|
227
|
+
const previous = this.capacityQueue;
|
|
228
|
+
this.capacityQueue = new Promise((resolve) => {
|
|
229
|
+
unlock = resolve;
|
|
230
|
+
});
|
|
231
|
+
await previous;
|
|
232
|
+
try {
|
|
233
|
+
if (this.activePublishes >= this.maxConcurrentPublishes) {
|
|
234
|
+
throw new ArtifactStoreError(429, 'Artifact publish concurrency limit reached');
|
|
235
|
+
}
|
|
236
|
+
const usage = await this.storageUsage();
|
|
237
|
+
if (usage.bytes + this.reservedBytes + expectedBytes > this.maxStorageBytes) {
|
|
238
|
+
throw new ArtifactStoreError(507, 'Artifact storage capacity reached');
|
|
239
|
+
}
|
|
240
|
+
if (usage.files + this.reservedFiles + 1 > this.maxFiles) {
|
|
241
|
+
throw new ArtifactStoreError(507, 'Artifact file count limit reached');
|
|
242
|
+
}
|
|
243
|
+
this.activePublishes += 1;
|
|
244
|
+
this.reservedBytes += expectedBytes;
|
|
245
|
+
this.reservedFiles += 1;
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
unlock();
|
|
249
|
+
}
|
|
250
|
+
let released = false;
|
|
251
|
+
return () => {
|
|
252
|
+
if (released)
|
|
253
|
+
return;
|
|
254
|
+
released = true;
|
|
255
|
+
this.activePublishes = Math.max(0, this.activePublishes - 1);
|
|
256
|
+
this.reservedBytes = Math.max(0, this.reservedBytes - expectedBytes);
|
|
257
|
+
this.reservedFiles = Math.max(0, this.reservedFiles - 1);
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async storageUsage() {
|
|
261
|
+
await this.ensureDirectory();
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
let bytes = 0;
|
|
264
|
+
let files = 0;
|
|
265
|
+
for (const name of await readdir(this.directory)) {
|
|
266
|
+
const parsed = parseStoredFilename(name);
|
|
267
|
+
if (parsed?.expiresAt !== undefined && parsed.expiresAt <= now) {
|
|
268
|
+
await rm(path.join(this.directory, name), { force: true });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const details = await stat(path.join(this.directory, name));
|
|
273
|
+
if (!details.isFile())
|
|
274
|
+
continue;
|
|
275
|
+
bytes += details.size;
|
|
276
|
+
files += 1;
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error.code !== 'ENOENT')
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { bytes, files };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export function contentDisposition(name) {
|
|
287
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
288
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
289
|
+
}
|
|
290
|
+
export function mediaTypeForName(name) {
|
|
291
|
+
const types = {
|
|
292
|
+
'.avif': 'image/avif',
|
|
293
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
294
|
+
'.gif': 'image/gif',
|
|
295
|
+
'.html': 'text/html; charset=utf-8',
|
|
296
|
+
'.jpeg': 'image/jpeg',
|
|
297
|
+
'.jpg': 'image/jpeg',
|
|
298
|
+
'.json': 'application/json; charset=utf-8',
|
|
299
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
300
|
+
'.mp3': 'audio/mpeg',
|
|
301
|
+
'.mp4': 'video/mp4',
|
|
302
|
+
'.ogg': 'audio/ogg',
|
|
303
|
+
'.pdf': 'application/pdf',
|
|
304
|
+
'.png': 'image/png',
|
|
305
|
+
'.svg': 'image/svg+xml',
|
|
306
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
307
|
+
'.wav': 'audio/wav',
|
|
308
|
+
'.webm': 'video/webm',
|
|
309
|
+
'.webp': 'image/webp',
|
|
310
|
+
'.zip': 'application/zip'
|
|
311
|
+
};
|
|
312
|
+
return types[path.extname(name).toLowerCase()] || 'application/octet-stream';
|
|
313
|
+
}
|
|
314
|
+
function artifactUrl(id, expiresAt, name) {
|
|
315
|
+
return `/v1/artifacts/${id}/${expiresAt}/${encodeURIComponent(name)}`;
|
|
316
|
+
}
|
|
317
|
+
function storedFilename(id, expiresAt, name) {
|
|
318
|
+
return `${id}-${expiresAt}-${name}`;
|
|
319
|
+
}
|
|
320
|
+
function parseStoredFilename(value) {
|
|
321
|
+
const match = /^([a-f0-9]{64})-(\d+)-(.+)$/.exec(value);
|
|
322
|
+
if (!match)
|
|
323
|
+
return undefined;
|
|
324
|
+
const expiresAt = Number(match[2]);
|
|
325
|
+
if (!Number.isSafeInteger(expiresAt))
|
|
326
|
+
return undefined;
|
|
327
|
+
return { id: match[1], expiresAt, name: match[3] };
|
|
328
|
+
}
|
|
329
|
+
function safeFilename(value) {
|
|
330
|
+
const basename = path.basename(String(value || 'artifact').replace(/\\/g, '/'));
|
|
331
|
+
const safe = basename.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').trim().slice(0, 180);
|
|
332
|
+
return safe || 'artifact';
|
|
333
|
+
}
|
|
334
|
+
function publicView(value) {
|
|
335
|
+
const { filePath: _filePath, handle: _handle, ...result } = value;
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
function errorMessage(error) {
|
|
339
|
+
return error instanceof Error ? error.message : String(error);
|
|
340
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function hashAdminPassword(input: string): Promise<string>;
|
|
2
|
+
export declare function verifyAdminPassword(input: string, encoded?: string): Promise<boolean>;
|
|
3
|
+
export declare function validateAdminPassword(input: string): string;
|
|
4
|
+
export declare class AdminSessionStore {
|
|
5
|
+
private readonly ttlMs;
|
|
6
|
+
private readonly sessions;
|
|
7
|
+
constructor(ttlMs: number);
|
|
8
|
+
create(): string;
|
|
9
|
+
has(id: string): boolean;
|
|
10
|
+
delete(id: string): void;
|
|
11
|
+
clear(): void;
|
|
12
|
+
private cleanup;
|
|
13
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const scrypt = promisify(scryptCallback);
|
|
4
|
+
const KEY_LENGTH = 64;
|
|
5
|
+
const PASSWORD_MIN_LENGTH = 12;
|
|
6
|
+
export async function hashAdminPassword(input) {
|
|
7
|
+
const password = validateAdminPassword(input);
|
|
8
|
+
const salt = randomBytes(16);
|
|
9
|
+
const derived = (await scrypt(password, salt, KEY_LENGTH));
|
|
10
|
+
return `scrypt$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
|
11
|
+
}
|
|
12
|
+
export async function verifyAdminPassword(input, encoded) {
|
|
13
|
+
if (!encoded)
|
|
14
|
+
return false;
|
|
15
|
+
const [algorithm, saltValue, hashValue, extra] = encoded.split('$');
|
|
16
|
+
if (algorithm !== 'scrypt' || !saltValue || !hashValue || extra !== undefined) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const expected = Buffer.from(hashValue, 'base64url');
|
|
21
|
+
if (expected.length !== KEY_LENGTH)
|
|
22
|
+
return false;
|
|
23
|
+
const actual = (await scrypt(input, Buffer.from(saltValue, 'base64url'), expected.length));
|
|
24
|
+
return timingSafeEqual(actual, expected);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function validateAdminPassword(input) {
|
|
31
|
+
const value = typeof input === 'string' ? input : '';
|
|
32
|
+
if (value.length < PASSWORD_MIN_LENGTH) {
|
|
33
|
+
throw new Error(`Console Password must contain at least ${PASSWORD_MIN_LENGTH} characters`);
|
|
34
|
+
}
|
|
35
|
+
if (Buffer.byteLength(value, 'utf8') > 1024) {
|
|
36
|
+
throw new Error('Console Password must not exceed 1024 bytes');
|
|
37
|
+
}
|
|
38
|
+
if (/\u0000/.test(value))
|
|
39
|
+
throw new Error('Console Password must not contain NUL bytes');
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
export class AdminSessionStore {
|
|
43
|
+
ttlMs;
|
|
44
|
+
sessions = new Map();
|
|
45
|
+
constructor(ttlMs) {
|
|
46
|
+
this.ttlMs = ttlMs;
|
|
47
|
+
}
|
|
48
|
+
create() {
|
|
49
|
+
this.cleanup();
|
|
50
|
+
const id = randomBytes(32).toString('base64url');
|
|
51
|
+
this.sessions.set(id, Date.now() + this.ttlMs);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
has(id) {
|
|
55
|
+
const expiresAt = this.sessions.get(id);
|
|
56
|
+
if (!expiresAt)
|
|
57
|
+
return false;
|
|
58
|
+
if (expiresAt <= Date.now()) {
|
|
59
|
+
this.sessions.delete(id);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
delete(id) {
|
|
65
|
+
this.sessions.delete(id);
|
|
66
|
+
}
|
|
67
|
+
clear() {
|
|
68
|
+
this.sessions.clear();
|
|
69
|
+
}
|
|
70
|
+
cleanup() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const [id, expiresAt] of this.sessions) {
|
|
73
|
+
if (expiresAt <= now)
|
|
74
|
+
this.sessions.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { networkInterfaces } from 'node:os';
|
|
4
|
+
import { ensureAgentdConfig } from './config.js';
|
|
3
5
|
import { startAgentd } from './index.js';
|
|
4
|
-
const
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const configPath = resolveConfigPath(args);
|
|
8
|
+
const bootstrap = await ensureAgentdConfig(configPath, {
|
|
9
|
+
host: option(args, '--host'),
|
|
10
|
+
port: numberOption(args, '--port'),
|
|
11
|
+
workspace: option(args, '--workspace')
|
|
12
|
+
});
|
|
5
13
|
const runtime = await startAgentd(configPath);
|
|
6
14
|
const address = runtime.server.address();
|
|
7
15
|
const label = typeof address === 'object' && address
|
|
8
16
|
? `${address.address}:${address.port}`
|
|
9
17
|
: String(address);
|
|
10
18
|
console.log(`nexus-agentd listening on ${label}`);
|
|
19
|
+
for (const url of displayUrls(address))
|
|
20
|
+
console.log(`WebUI: ${url}`);
|
|
21
|
+
if (bootstrap.created) {
|
|
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
|
+
}
|
|
27
|
+
}
|
|
11
28
|
let closing = false;
|
|
12
29
|
const close = async () => {
|
|
13
30
|
if (closing)
|
|
@@ -22,3 +39,43 @@ function resolveConfigPath(args) {
|
|
|
22
39
|
const value = index >= 0 ? args[index + 1] : process.env.NEXUS_AGENTD_CONFIG;
|
|
23
40
|
return path.resolve(value || 'nexus-agentd.json');
|
|
24
41
|
}
|
|
42
|
+
function option(args, name) {
|
|
43
|
+
const index = args.indexOf(name);
|
|
44
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
45
|
+
}
|
|
46
|
+
function numberOption(args, name) {
|
|
47
|
+
const value = option(args, name);
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
const number = Number(value);
|
|
51
|
+
if (!Number.isInteger(number))
|
|
52
|
+
throw new Error(`${name} must be an integer`);
|
|
53
|
+
return number;
|
|
54
|
+
}
|
|
55
|
+
function displayHost(address) {
|
|
56
|
+
if (!address || typeof address === 'string')
|
|
57
|
+
return '127.0.0.1';
|
|
58
|
+
if (address.address === '0.0.0.0' || address.address === '::') {
|
|
59
|
+
return '127.0.0.1';
|
|
60
|
+
}
|
|
61
|
+
return address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
|
62
|
+
}
|
|
63
|
+
function displayPort(address) {
|
|
64
|
+
return address && typeof address === 'object' ? address.port : runtime.config.listen.port;
|
|
65
|
+
}
|
|
66
|
+
function displayUrls(address) {
|
|
67
|
+
const port = displayPort(address);
|
|
68
|
+
if (address &&
|
|
69
|
+
typeof address === 'object' &&
|
|
70
|
+
(address.address === '0.0.0.0' || address.address === '::')) {
|
|
71
|
+
const hosts = new Set(['127.0.0.1']);
|
|
72
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
73
|
+
for (const entry of entries || []) {
|
|
74
|
+
if (entry.family === 'IPv4' && !entry.internal)
|
|
75
|
+
hosts.add(entry.address);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return Array.from(hosts).map((host) => `http://${host}:${port}/ui/`);
|
|
79
|
+
}
|
|
80
|
+
return [`http://${displayHost(address)}:${port}/ui/`];
|
|
81
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
import { type AgentdConfig } from './types.js';
|
|
2
|
+
export interface AgentdBootstrapOptions {
|
|
3
|
+
host?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
workspace?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function ensureAgentdConfig(filePath: string, options?: AgentdBootstrapOptions): Promise<{
|
|
8
|
+
created: false;
|
|
9
|
+
} | {
|
|
10
|
+
created: true;
|
|
11
|
+
}>;
|
|
2
12
|
export declare function loadAgentdConfig(filePath: string): Promise<AgentdConfig>;
|
|
3
13
|
export declare function resolveSecret(value: string): string;
|