nexus-agentd 0.2.5 → 0.2.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/dist/session.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { open, realpath } from 'node:fs/promises';
3
- import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
4
  import { AcpProcessRuntime } from './acp/runtime.js';
5
5
  import { A2AClientRuntime, probeA2AAgent } from './a2a/runtime.js';
6
6
  import { SessionEventLog } from './events.js';
7
+ import { ContainedReadError, readContainedFile } from './workspace.js';
7
8
  const MAX_SESSION_ARTIFACTS = 64;
8
9
  const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
9
10
  const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
@@ -193,40 +194,21 @@ export class ManagedSession {
193
194
  return artifacts;
194
195
  }
195
196
  async publishSingleFile(requestedPath) {
196
- let handle;
197
197
  try {
198
198
  const workspaceRoot = await realpath(this.workspace);
199
- const unresolved = resolve(workspaceRoot, requestedPath);
200
- const target = await realpath(unresolved);
201
- assertWorkspacePath(workspaceRoot, target);
202
- handle = await open(target, 'r');
203
- // Resolve again after opening so a swapped symlink cannot silently
204
- // redirect a publish request outside the configured workspace.
205
- const verifiedTarget = await realpath(unresolved);
206
- assertWorkspacePath(workspaceRoot, verifiedTarget);
207
- if (verifiedTarget !== target) {
208
- throw new SessionRequestError(409, 'Published file changed while it was being opened');
209
- }
210
- const stats = await handle.stat();
211
- if (!stats.isFile()) {
212
- throw new SessionRequestError(400, 'Only regular files can be published');
213
- }
214
- if (stats.size > MAX_PUBLISHED_FILE_BYTES) {
215
- throw new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
216
- }
217
- const bytes = await handle.readFile();
218
- const filename = basename(verifiedTarget);
219
- const workspacePath = relative(workspaceRoot, verifiedTarget).split(sep).join('/');
199
+ const file = await readContainedFile([workspaceRoot], requestedPath, {
200
+ maxBytes: MAX_PUBLISHED_FILE_BYTES
201
+ });
220
202
  const artifact = {
221
203
  id: `published:${randomUUID()}`,
222
- name: filename,
223
- filename,
224
- mediaType: mediaTypeForPath(filename),
225
- bytesBase64: bytes.toString('base64'),
204
+ name: file.filename,
205
+ filename: file.filename,
206
+ mediaType: mediaTypeForPath(file.filename),
207
+ bytesBase64: file.bytes.toString('base64'),
226
208
  metadata: {
227
209
  source: 'workspace_publish',
228
- path: workspacePath,
229
- size: bytes.length
210
+ path: file.relative,
211
+ size: file.size
230
212
  }
231
213
  };
232
214
  this.addArtifact(artifact);
@@ -236,6 +218,8 @@ export class ManagedSession {
236
218
  catch (error) {
237
219
  if (error instanceof SessionRequestError)
238
220
  throw error;
221
+ if (error instanceof ContainedReadError)
222
+ throw publishFailure(error);
239
223
  const code = error?.code;
240
224
  if (code === 'ENOENT') {
241
225
  throw new SessionRequestError(404, 'Published file was not found');
@@ -245,9 +229,6 @@ export class ManagedSession {
245
229
  }
246
230
  throw error;
247
231
  }
248
- finally {
249
- await handle?.close();
250
- }
251
232
  }
252
233
  setPending(request) {
253
234
  this.completion = undefined;
@@ -297,7 +278,7 @@ export class ManagedSession {
297
278
  return attachmentView(attachment);
298
279
  }
299
280
  async message(message, attachmentIds = []) {
300
- if (!this.runtime)
281
+ if (!this.runtime || this.runtime.isAvailable?.() === false)
301
282
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
302
283
  const attachments = this.inputAttachmentsFor(attachmentIds);
303
284
  if (this.pendingRequest) {
@@ -326,13 +307,14 @@ export class ManagedSession {
326
307
  task: message,
327
308
  inputAttachmentCount: attachments.length
328
309
  }).id;
310
+ this.setState('running');
329
311
  void this.runtime.prompt(message, attachments).catch((error) => {
330
312
  if (this.state !== 'canceled')
331
313
  this.setState('failed', errorMessage(error));
332
314
  });
333
315
  }
334
316
  async resolvePending(response, attachmentIds = []) {
335
- if (!this.runtime) {
317
+ if (!this.runtime || this.runtime.isAvailable?.() === false) {
336
318
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
337
319
  }
338
320
  const pending = this.pendingRequest;
@@ -349,11 +331,31 @@ export class ManagedSession {
349
331
  await this.runtime.respondPending(response, attachments);
350
332
  }
351
333
  async cancel() {
352
- await this.runtime?.cancel();
334
+ const runtime = this.runtime;
335
+ this.runtime = undefined;
336
+ this.clearPending();
337
+ this.setState('canceled');
338
+ try {
339
+ await runtime?.cancel();
340
+ }
341
+ finally {
342
+ try {
343
+ await runtime?.dispose();
344
+ }
345
+ finally {
346
+ this.inputAttachments.clear();
347
+ }
348
+ }
353
349
  }
354
350
  async dispose() {
355
- await this.runtime?.dispose();
356
- this.inputAttachments.clear();
351
+ const runtime = this.runtime;
352
+ this.runtime = undefined;
353
+ try {
354
+ await runtime?.dispose();
355
+ }
356
+ finally {
357
+ this.inputAttachments.clear();
358
+ }
357
359
  }
358
360
  snapshot() {
359
361
  return {
@@ -535,7 +537,7 @@ export class SessionManager {
535
537
  const session = new ManagedSession(agentId, config.protocol === 'a2a' ? 'a2a' : 'acp', workspace, ownerKeyId, this.config.maxEventsPerSession, this.config.maxOutputChars, config.name || agentId, this.runStore, this.instanceId);
536
538
  const runtime = config.protocol === 'a2a'
537
539
  ? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
538
- : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000);
540
+ : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
539
541
  this.sessions.set(session.id, session);
540
542
  session.attach(runtime);
541
543
  try {
@@ -544,8 +546,15 @@ export class SessionManager {
544
546
  }
545
547
  catch (error) {
546
548
  session.setState('failed', errorMessage(error));
547
- await session.dispose();
548
- this.sessions.delete(session.id);
549
+ try {
550
+ await session.dispose();
551
+ }
552
+ catch (cleanupError) {
553
+ console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
554
+ }
555
+ finally {
556
+ this.sessions.delete(session.id);
557
+ }
549
558
  throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
550
559
  }
551
560
  }
@@ -559,7 +568,7 @@ export class SessionManager {
559
568
  return this.sessions.size;
560
569
  }
561
570
  listRuns(query = {}) {
562
- return this.runStore?.list(query) || { runs: [], total: 0 };
571
+ return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
563
572
  }
564
573
  getRun(id) {
565
574
  const run = this.runStore?.get(id);
@@ -678,17 +687,29 @@ export class SessionManager {
678
687
  for (const [id, session] of this.sessions) {
679
688
  if (session.updatedAt > cutoff)
680
689
  continue;
681
- if (!isTerminal(session.state)) {
682
- session.setState('failed', 'Session expired after exceeding its lifetime');
683
- }
684
- await session.dispose();
685
- this.sessions.delete(id);
690
+ await this.withSessionLock(id, async () => {
691
+ if (this.sessions.get(id) !== session || session.updatedAt > cutoff)
692
+ return;
693
+ if (!isTerminal(session.state))
694
+ session.setState('failed', 'Session expired after exceeding its lifetime');
695
+ try {
696
+ await session.dispose();
697
+ }
698
+ catch (error) {
699
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
700
+ }
701
+ finally {
702
+ this.sessions.delete(id);
703
+ }
704
+ });
686
705
  }
687
706
  }
688
707
  restartCleanup() {
689
708
  if (this.cleanupTimer)
690
709
  clearInterval(this.cleanupTimer);
691
- this.cleanupTimer = setInterval(() => void this.cleanup(), this.config.cleanupIntervalMs || 60_000);
710
+ this.cleanupTimer = setInterval(() => void this.cleanup().catch((error) => {
711
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', message: errorMessage(error) }));
712
+ }), this.config.cleanupIntervalMs || 60_000);
692
713
  this.cleanupTimer.unref?.();
693
714
  }
694
715
  defaultWorkspace(agentId) {
@@ -746,10 +767,16 @@ function runArtifact(artifact) {
746
767
  metadata: artifact.metadata ? structuredClone(artifact.metadata) : undefined
747
768
  };
748
769
  }
749
- function assertWorkspacePath(workspaceRoot, target) {
750
- const child = relative(workspaceRoot, target);
751
- if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) {
752
- throw new SessionRequestError(403, 'Published files must stay inside the session workspace');
770
+ function publishFailure(error) {
771
+ switch (error.failure) {
772
+ case 'swapped':
773
+ return new SessionRequestError(409, 'Published file changed while it was being opened');
774
+ case 'not-a-file':
775
+ return new SessionRequestError(400, 'Only regular files can be published');
776
+ case 'too-large':
777
+ return new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
778
+ case 'outside':
779
+ return new SessionRequestError(403, 'Published files must stay inside the session workspace');
753
780
  }
754
781
  }
755
782
  function mediaTypeForPath(path) {