nexus-agentd 0.2.3 → 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.
@@ -44,6 +44,8 @@ export class A2AClientRuntime {
44
44
  taskId = '';
45
45
  contextId = '';
46
46
  prompting = false;
47
+ promptFinished;
48
+ queuedResponse = false;
47
49
  disposed = false;
48
50
  activeController;
49
51
  artifactCache = new Map();
@@ -57,6 +59,8 @@ export class A2AClientRuntime {
57
59
  this.promptTimeoutMs = promptTimeoutMs;
58
60
  }
59
61
  async start() {
62
+ if (this.disposed)
63
+ throw new Error('A2A runtime is disposed');
60
64
  const resolver = createResolver(this.config);
61
65
  this.card = await resolveAgentCard(resolver, this.config);
62
66
  const fetchImpl = authenticatedFetch(this.config);
@@ -70,6 +74,8 @@ export class A2AClientRuntime {
70
74
  ]
71
75
  });
72
76
  this.client = await factory.createFromAgentCard(this.card);
77
+ if (this.disposed)
78
+ throw new Error('A2A runtime is disposed');
73
79
  this.sink.setState('created');
74
80
  }
75
81
  async prompt(message, attachments = []) {
@@ -80,6 +86,8 @@ export class A2AClientRuntime {
80
86
  if (this.disposed)
81
87
  throw new Error('A2A runtime is disposed');
82
88
  this.prompting = true;
89
+ let finish;
90
+ this.promptFinished = new Promise((resolve) => { finish = resolve; });
83
91
  this.sawTaskStatus = false;
84
92
  this.sawAgentMessage = false;
85
93
  this.completionProof = undefined;
@@ -94,9 +102,15 @@ export class A2AClientRuntime {
94
102
  for await (const response of this.client.sendMessageStream(request, {
95
103
  signal: controller.signal
96
104
  })) {
105
+ if (controller.signal.aborted || this.disposed)
106
+ break;
107
+ // An accepted answer supersedes the old input-required stream.
108
+ // Its trailing status must not reopen the answered request.
109
+ if (this.queuedResponse)
110
+ continue;
97
111
  this.processResponse(response);
98
112
  }
99
- if (this.sink.state === 'running') {
113
+ if (this.sink.state === 'running' && !this.queuedResponse) {
100
114
  if (this.completionProof) {
101
115
  this.sink.completeTurn(this.completionProof);
102
116
  }
@@ -114,7 +128,7 @@ export class A2AClientRuntime {
114
128
  }
115
129
  }
116
130
  catch (error) {
117
- if (this.sink.state !== 'canceled') {
131
+ if (this.sink.state !== 'canceled' && !this.queuedResponse) {
118
132
  this.sink.setState('failed', abortMessage(error, controller.signal));
119
133
  }
120
134
  }
@@ -123,9 +137,13 @@ export class A2AClientRuntime {
123
137
  if (this.activeController === controller)
124
138
  this.activeController = undefined;
125
139
  this.prompting = false;
140
+ finish();
141
+ this.promptFinished = undefined;
126
142
  }
127
143
  }
128
144
  async respondPending(response, attachments = []) {
145
+ if (!this.isAvailable())
146
+ throw new Error('A2A runtime is unavailable');
129
147
  if (this.sink.state !== 'input_required') {
130
148
  throw new Error('A2A session is not waiting for input');
131
149
  }
@@ -138,10 +156,23 @@ export class A2AClientRuntime {
138
156
  : response.message ?? response.optionId ?? '';
139
157
  if (!message.trim())
140
158
  throw new Error('A2A pending response is empty');
159
+ const previous = this.promptFinished || Promise.resolve();
160
+ this.queuedResponse = true;
141
161
  this.sink.clearPending();
142
- await this.prompt(message, attachments);
162
+ this.sink.setState('running');
163
+ void previous.then(async () => {
164
+ this.queuedResponse = false;
165
+ if (this.disposed || this.sink.state === 'canceled')
166
+ return;
167
+ await this.prompt(message, attachments);
168
+ }).catch((error) => {
169
+ if (!this.disposed && this.sink.state !== 'canceled')
170
+ this.sink.setState('failed', errorMessage(error));
171
+ });
143
172
  }
144
173
  async cancel() {
174
+ this.sink.clearPending();
175
+ this.sink.setState('canceled');
145
176
  this.activeController?.abort(new Error('A2A session canceled'));
146
177
  if (this.client && this.taskId) {
147
178
  try {
@@ -162,6 +193,9 @@ export class A2AClientRuntime {
162
193
  this.activeController?.abort(new Error('A2A runtime disposed'));
163
194
  this.activeController = undefined;
164
195
  }
196
+ isAvailable() {
197
+ return !this.disposed && Boolean(this.client && this.card);
198
+ }
165
199
  messageRequest(text, attachments) {
166
200
  return {
167
201
  tenant: '',
@@ -280,13 +314,16 @@ export class A2AClientRuntime {
280
314
  this.sink.clearPending();
281
315
  this.sink.setState('canceled');
282
316
  break;
283
- case TaskState.TASK_STATE_INPUT_REQUIRED:
317
+ case TaskState.TASK_STATE_INPUT_REQUIRED: {
318
+ const classification = a2aInputClassification(statusText, status.message);
284
319
  this.sink.setPending({
285
320
  id: this.taskId || randomUUID(),
286
321
  kind: 'input',
287
- prompt: statusText || 'The A2A agent requires additional input.'
322
+ prompt: statusText || 'The A2A agent requires additional input.',
323
+ ...classification
288
324
  });
289
325
  break;
326
+ }
290
327
  case TaskState.TASK_STATE_FAILED:
291
328
  case TaskState.TASK_STATE_REJECTED:
292
329
  case TaskState.TASK_STATE_AUTH_REQUIRED:
@@ -335,12 +372,43 @@ function createResolver(config) {
335
372
  legacyCompat: { enabled: true }
336
373
  });
337
374
  }
338
- function resolveAgentCard(resolver, config) {
339
- if (config.agentCardUrl)
340
- return resolver.resolve(config.agentCardUrl, '');
341
- if (config.agentUrl)
342
- return resolver.resolve(config.agentUrl);
343
- throw new Error('A2A Agent Card URL is required');
375
+ async function resolveAgentCard(resolver, config) {
376
+ const origin = configuredOrigin(config);
377
+ const card = config.agentCardUrl
378
+ ? await resolver.resolve(config.agentCardUrl, '')
379
+ : await resolver.resolve(config.agentUrl);
380
+ // The resolver normalizes v0.3 url/additionalInterfaces into this array.
381
+ // Validate every advertised endpoint before transport selection or probing.
382
+ if (!Array.isArray(card.supportedInterfaces) || !card.supportedInterfaces.length) {
383
+ throw new Error('A2A Agent Card must declare at least one interface URL');
384
+ }
385
+ for (const endpoint of card.supportedInterfaces)
386
+ assertSameOrigin(endpoint?.url, origin);
387
+ return card;
388
+ }
389
+ function a2aUrl(value) {
390
+ let url;
391
+ try {
392
+ url = new URL(value);
393
+ }
394
+ catch {
395
+ throw new Error('A2A URL is invalid');
396
+ }
397
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
398
+ throw new Error('A2A URL must use http(s) without credentials or a fragment');
399
+ }
400
+ return url;
401
+ }
402
+ function configuredOrigin(config) {
403
+ const value = config.agentCardUrl || config.agentUrl;
404
+ if (!value)
405
+ throw new Error('A2A Agent Card URL is required');
406
+ return a2aUrl(value).origin;
407
+ }
408
+ function assertSameOrigin(value, origin) {
409
+ if (a2aUrl(value).origin !== origin) {
410
+ throw new Error('A2A URL must have the same origin as the configured Agent Card URL');
411
+ }
344
412
  }
345
413
  function preferredTransports(config) {
346
414
  if (config.preferredTransport === 'jsonrpc')
@@ -350,8 +418,12 @@ function preferredTransports(config) {
350
418
  return undefined;
351
419
  }
352
420
  function authenticatedFetch(config) {
421
+ const origin = configuredOrigin(config);
353
422
  return async (input, init = {}) => {
354
- const headers = new Headers(init.headers);
423
+ // The final guard also covers SDK-derived paths and future card flows.
424
+ // Check before attaching credentials; never let fetch follow redirects.
425
+ assertSameOrigin(input instanceof Request ? input.url : String(input), origin);
426
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
355
427
  if (config.auth?.type === 'bearer' && config.auth.value) {
356
428
  headers.set('Authorization', `Bearer ${config.auth.value}`);
357
429
  }
@@ -363,6 +435,7 @@ function authenticatedFetch(config) {
363
435
  return fetch(input, {
364
436
  ...init,
365
437
  headers,
438
+ redirect: 'error',
366
439
  signal: init.signal || AbortSignal.timeout(config.timeoutMs || 60_000)
367
440
  });
368
441
  };
@@ -450,3 +523,37 @@ function disabledView(id, config) {
450
523
  function errorMessage(error) {
451
524
  return error instanceof Error ? error.message : String(error);
452
525
  }
526
+ /**
527
+ * Detect payment / confirmation / choice hints in an A2A `input_required`
528
+ * message so the Gateway can surface them on `pendingRequest.metadata` and
529
+ * `inputType`. The A2A protocol has no first-class field for these so we
530
+ * scan text parts for URLs and simple keywords.
531
+ */
532
+ function a2aInputClassification(statusText, message) {
533
+ const metadata = {};
534
+ const haystack = [
535
+ statusText,
536
+ ...(message?.parts ? textParts(message.parts) : [])
537
+ ]
538
+ .filter(Boolean)
539
+ .join('\n');
540
+ const urlMatch = haystack.match(/https?:\/\/\S+/);
541
+ if (urlMatch)
542
+ metadata.url = urlMatch[0];
543
+ let inputType = 'text';
544
+ if (/pay|支付|invoice|checkout|订单支付/i.test(haystack)) {
545
+ inputType = 'payment';
546
+ if (urlMatch)
547
+ metadata.paymentUrl = urlMatch[0];
548
+ }
549
+ else if (/[((]\s*[yY]\s*\/\s*[nN]\s*[))]|确认|是否/i.test(haystack)) {
550
+ inputType = 'confirmation';
551
+ }
552
+ else if (/(?:\n|^)\s*(?:\d+[.、]|[-*])\s+\S/.test(haystack)) {
553
+ inputType = 'choice';
554
+ }
555
+ return {
556
+ inputType,
557
+ ...(Object.keys(metadata).length ? { metadata } : {})
558
+ };
559
+ }
@@ -4,6 +4,8 @@ import type { AgentdInputAttachment, AgentdPendingResponse } from '../types.js';
4
4
  export declare class AcpProcessRuntime {
5
5
  private readonly driver;
6
6
  private readonly sink;
7
+ private readonly allowedRoots;
8
+ private readonly startupTimeoutMs;
7
9
  private process?;
8
10
  private connection?;
9
11
  private pending?;
@@ -16,12 +18,14 @@ export declare class AcpProcessRuntime {
16
18
  private inputDirectory?;
17
19
  private workspace?;
18
20
  private promptOutput;
19
- constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number);
21
+ constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number, allowedRoots?: readonly string[], startupTimeoutMs?: number);
20
22
  start(workspace: string): Promise<void>;
23
+ private connect;
21
24
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
22
25
  respondPending(input: AgentdPendingResponse | string, _attachments?: AgentdInputAttachment[]): Promise<void>;
23
26
  cancel(): Promise<void>;
24
27
  dispose(): Promise<void>;
28
+ isAvailable(): boolean;
25
29
  private requestPermission;
26
30
  private finishPermission;
27
31
  private requestInput;
@@ -1,15 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { terminateProcessTree } from '../process-tree.js';
8
8
  import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
9
- const MAX_MEDIA_MARKER_FILE_BYTES = 32 * 1024 * 1024;
9
+ import { readContainedFile } from '../workspace.js';
10
+ // Matches MAX_PUBLISHED_FILE_BYTES in session.ts; larger files are encoded only to be dropped by the base64 budget.
11
+ const MAX_MEDIA_MARKER_FILE_BYTES = 12 * 1024 * 1024;
12
+ // The agent controls how many markers it emits, so bound the reads per turn.
13
+ const MAX_MEDIA_MARKER_ARTIFACTS = 8;
10
14
  export class AcpProcessRuntime {
11
15
  driver;
12
16
  sink;
17
+ allowedRoots;
18
+ startupTimeoutMs;
13
19
  process;
14
20
  connection;
15
21
  pending;
@@ -22,9 +28,11 @@ export class AcpProcessRuntime {
22
28
  inputDirectory;
23
29
  workspace;
24
30
  promptOutput = '';
25
- constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
31
+ constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000, allowedRoots = [], startupTimeoutMs = 30_000) {
26
32
  this.driver = driver;
27
33
  this.sink = sink;
34
+ this.allowedRoots = allowedRoots;
35
+ this.startupTimeoutMs = startupTimeoutMs;
28
36
  const limit = Number.isFinite(maxStderrChunkChars)
29
37
  ? Math.floor(maxStderrChunkChars)
30
38
  : 16 * 1024;
@@ -32,6 +40,21 @@ export class AcpProcessRuntime {
32
40
  this.promptTimeoutMs = Math.max(10_000, Math.trunc(promptTimeoutMs));
33
41
  }
34
42
  async start(workspace) {
43
+ if (this.disposed)
44
+ throw new Error('ACP runtime is disposed');
45
+ if (this.process)
46
+ throw new Error('ACP runtime is already started');
47
+ try {
48
+ await withTimeout(this.connect(workspace), this.startupTimeoutMs, 'ACP startup timed out');
49
+ }
50
+ catch (error) {
51
+ if (this.sink.state !== 'canceled')
52
+ this.onProcessFailure(error);
53
+ await this.dispose();
54
+ throw error;
55
+ }
56
+ }
57
+ async connect(workspace) {
35
58
  if (this.process)
36
59
  throw new Error('ACP runtime is already started');
37
60
  this.workspace = workspace;
@@ -69,6 +92,8 @@ export class AcpProcessRuntime {
69
92
  version: '0.2.2'
70
93
  }
71
94
  });
95
+ if (this.disposed)
96
+ throw new Error('ACP runtime is disposed');
72
97
  if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
73
98
  this.sink.emit('terminal_output', {
74
99
  stream: 'system',
@@ -80,10 +105,14 @@ export class AcpProcessRuntime {
80
105
  cwd: workspace,
81
106
  mcpServers: []
82
107
  });
108
+ if (this.disposed)
109
+ throw new Error('ACP runtime is disposed');
83
110
  this.sink.setAcpSessionId(String(session.sessionId));
84
111
  this.sink.setState('created');
85
112
  }
86
113
  async prompt(message, attachments = []) {
114
+ if (this.disposed)
115
+ throw new Error('ACP runtime is disposed');
87
116
  if (!this.connection)
88
117
  throw new Error('ACP runtime is not connected');
89
118
  if (this.prompting)
@@ -199,11 +228,12 @@ export class AcpProcessRuntime {
199
228
  if (this.pendingInput) {
200
229
  this.finishInput('cancel');
201
230
  }
202
- if (this.connection && this.sink.state !== 'canceled') {
231
+ this.sink.setState('canceled');
232
+ if (this.connection) {
203
233
  try {
204
- await this.connection.agent.notify(acp.methods.agent.session.cancel, {
234
+ await withTimeout(this.connection.agent.notify(acp.methods.agent.session.cancel, {
205
235
  sessionId: this.requireSessionId()
206
- });
236
+ }), 2_000, 'ACP cancellation notification timed out');
207
237
  }
208
238
  catch (error) {
209
239
  this.sink.emit('terminal_output', {
@@ -229,8 +259,16 @@ export class AcpProcessRuntime {
229
259
  this.pendingInput.resolve({ action: 'cancel' });
230
260
  this.pendingInput = undefined;
231
261
  }
232
- this.connection?.close();
262
+ const connection = this.connection;
233
263
  this.connection = undefined;
264
+ try {
265
+ connection?.close();
266
+ }
267
+ catch (error) {
268
+ this.sink.emit('terminal_output', {
269
+ stream: 'system', text: `ACP connection close failed: ${error instanceof Error ? error.message : String(error)}`
270
+ });
271
+ }
234
272
  const child = this.process;
235
273
  this.process = undefined;
236
274
  if (child) {
@@ -243,6 +281,10 @@ export class AcpProcessRuntime {
243
281
  this.inputDirectory = undefined;
244
282
  }
245
283
  }
284
+ isAvailable() {
285
+ return !this.disposed && Boolean(this.connection) &&
286
+ (!this.process || (this.process.exitCode === null && this.process.signalCode === null));
287
+ }
246
288
  requestPermission(params) {
247
289
  if (this.driver.permissionPolicy === 'deny') {
248
290
  const reject = params.options.find((option) => option.kind.startsWith('reject'));
@@ -275,11 +317,16 @@ export class AcpProcessRuntime {
275
317
  kind: 'permission',
276
318
  prompt: params.toolCall.title ||
277
319
  `Permission requested for tool ${params.toolCall.toolCallId}`,
320
+ inputType: 'confirmation',
321
+ step: params.toolCall.toolCallId
322
+ ? `tool:${params.toolCall.toolCallId}`
323
+ : undefined,
278
324
  options: params.options.map((option) => ({
279
325
  id: option.optionId,
280
326
  name: option.name,
281
327
  kind: option.kind
282
- }))
328
+ })),
329
+ metadata: permissionMetadata(params)
283
330
  };
284
331
  this.sink.setPending(request);
285
332
  return new Promise((resolve, reject) => {
@@ -314,7 +361,8 @@ export class AcpProcessRuntime {
314
361
  prompt: params.mode === 'url'
315
362
  ? `${params.message}\n${params.url}`
316
363
  : params.message,
317
- options: elicitationOptions(params)
364
+ options: elicitationOptions(params),
365
+ ...elicitationClassification(params)
318
366
  };
319
367
  this.sink.setPending(request);
320
368
  return new Promise((resolve) => {
@@ -422,13 +470,23 @@ export class AcpProcessRuntime {
422
470
  if (!markers.length)
423
471
  return;
424
472
  this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
425
- const seen = new Set();
426
- for (const marker of markers) {
427
- if (seen.has(marker))
428
- continue;
429
- seen.add(marker);
473
+ const roots = this.allowedRoots.length
474
+ ? this.allowedRoots
475
+ : this.workspace
476
+ ? [this.workspace]
477
+ : [];
478
+ const unique = Array.from(new Set(markers));
479
+ const accepted = unique.slice(0, MAX_MEDIA_MARKER_ARTIFACTS);
480
+ const ignored = unique.length - accepted.length;
481
+ if (ignored > 0) {
482
+ this.sink.emit('terminal_output', {
483
+ stream: 'system',
484
+ text: `Ignored ${ignored} MEDIA marker(s); at most ${MAX_MEDIA_MARKER_ARTIFACTS} artifacts can be attached per turn.`
485
+ });
486
+ }
487
+ for (const marker of accepted) {
430
488
  try {
431
- const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, this.workspace);
489
+ const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, roots);
432
490
  this.sink.addArtifact(artifact);
433
491
  this.sink.emit('artifact', artifact);
434
492
  }
@@ -441,6 +499,11 @@ export class AcpProcessRuntime {
441
499
  }
442
500
  }
443
501
  captureStderr(child) {
502
+ // Explicit UTF-8 streaming decoder: `String(chunk)` on a Buffer uses the
503
+ // default binary encoding, which mangles multi-byte characters if a
504
+ // chunk boundary splits a rune. `TextDecoder` with `stream: true`
505
+ // preserves the split byte between calls.
506
+ const decoder = new TextDecoder('utf-8');
444
507
  let buffer = '';
445
508
  let flushed = false;
446
509
  const emit = (text) => {
@@ -471,10 +534,16 @@ export class AcpProcessRuntime {
471
534
  if (flushed)
472
535
  return;
473
536
  flushed = true;
537
+ buffer += decoder.decode();
474
538
  drain(true);
475
539
  };
476
540
  child.stderr.on('data', (chunk) => {
477
- buffer += String(chunk);
541
+ const bytes = chunk instanceof Uint8Array
542
+ ? chunk
543
+ : Buffer.isBuffer(chunk)
544
+ ? chunk
545
+ : Buffer.from(String(chunk));
546
+ buffer += decoder.decode(bytes, { stream: true });
478
547
  drain();
479
548
  });
480
549
  child.stderr.once('end', flush);
@@ -662,7 +731,7 @@ function withoutMediaMarkers(output) {
662
731
  .join('\n')
663
732
  .trim();
664
733
  }
665
- async function artifactFromMediaMarker(marker, id, workspace) {
734
+ async function artifactFromMediaMarker(marker, id, roots) {
666
735
  if (/^https?:\/\//i.test(marker)) {
667
736
  const filename = artifactFilename(marker, undefined, 'resource', undefined);
668
737
  return {
@@ -673,29 +742,19 @@ async function artifactFromMediaMarker(marker, id, workspace) {
673
742
  metadata: { source: 'acp_media_marker' }
674
743
  };
675
744
  }
676
- const localPath = marker.startsWith('file:')
677
- ? fileURLToPath(marker)
678
- : marker;
679
- const resolved = await realpath(path.isAbsolute(localPath)
680
- ? localPath
681
- : path.resolve(workspace || process.cwd(), localPath));
682
- const details = await stat(resolved);
683
- if (!details.isFile()) {
684
- throw new Error('declared path is not a regular file');
685
- }
686
- if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
687
- throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
688
- }
689
- const filename = path.basename(resolved);
745
+ const localPath = marker.startsWith('file:') ? fileURLToPath(marker) : marker;
746
+ const file = await readContainedFile(roots, localPath, {
747
+ maxBytes: MAX_MEDIA_MARKER_FILE_BYTES
748
+ });
690
749
  return {
691
750
  id,
692
- name: filename,
693
- filename,
694
- mediaType: mediaTypeForPath(filename),
695
- bytesBase64: (await readFile(resolved)).toString('base64'),
751
+ name: file.filename,
752
+ filename: file.filename,
753
+ mediaType: mediaTypeForPath(file.filename),
754
+ bytesBase64: file.bytes.toString('base64'),
696
755
  metadata: {
697
756
  source: 'acp_media_marker',
698
- size: details.size
757
+ size: file.size
699
758
  }
700
759
  };
701
760
  }
@@ -824,3 +883,88 @@ function isFormElicitation(params) {
824
883
  typeof params.requestedSchema ===
825
884
  'object'));
826
885
  }
886
+ /**
887
+ * Derive Agent Nexus semantic classification (inputType / step / metadata) from
888
+ * an ACP elicitation request. This is a best-effort projection: we look at the
889
+ * elicitation mode, the requested schema shape and well-known field names to
890
+ * decide whether the pending request is a plain text prompt, a choice, a
891
+ * yes/no confirmation, or a payment URL flow.
892
+ */
893
+ function elicitationClassification(params) {
894
+ const metadata = {};
895
+ let inputType = 'unknown';
896
+ let step;
897
+ if (params.mode === 'url') {
898
+ const url = String(params.url || '').trim();
899
+ if (url) {
900
+ metadata.paymentUrl = url;
901
+ metadata.url = url;
902
+ }
903
+ inputType = /pay|支付|invoice|order/i.test(`${params.message || ''} ${url}`)
904
+ ? 'payment'
905
+ : 'text';
906
+ step = 'url';
907
+ }
908
+ else if (isFormElicitation(params)) {
909
+ const properties = params.requestedSchema.properties || {};
910
+ const entries = Object.entries(properties);
911
+ step = entries.length === 1 ? entries[0][0] : 'form';
912
+ if (entries.length === 1) {
913
+ const [, schema] = entries[0];
914
+ if (schema.type === 'boolean')
915
+ inputType = 'confirmation';
916
+ else if (Array.isArray(schema.oneOf) ||
917
+ Array.isArray(schema.enum)) {
918
+ inputType = 'choice';
919
+ }
920
+ else {
921
+ inputType = 'text';
922
+ }
923
+ }
924
+ else {
925
+ inputType = 'text';
926
+ }
927
+ const paymentUrl = firstStringFromSchema(properties, [
928
+ 'paymentUrl',
929
+ 'payUrl',
930
+ 'payment_url',
931
+ 'checkoutUrl'
932
+ ]);
933
+ if (paymentUrl) {
934
+ metadata.paymentUrl = paymentUrl;
935
+ inputType = 'payment';
936
+ }
937
+ }
938
+ else {
939
+ inputType = 'text';
940
+ }
941
+ return {
942
+ inputType,
943
+ ...(step ? { step } : {}),
944
+ ...(Object.keys(metadata).length ? { metadata } : {})
945
+ };
946
+ }
947
+ function permissionMetadata(params) {
948
+ const metadata = {};
949
+ const call = params.toolCall;
950
+ if (typeof call?.toolCallId === 'string' && call.toolCallId) {
951
+ metadata.toolCallId = call.toolCallId;
952
+ }
953
+ if (typeof call?.name === 'string' && call.name) {
954
+ metadata.tool = call.name;
955
+ }
956
+ return Object.keys(metadata).length ? metadata : undefined;
957
+ }
958
+ function firstStringFromSchema(properties, candidates) {
959
+ for (const key of candidates) {
960
+ const schema = properties[key];
961
+ if (!schema)
962
+ continue;
963
+ const value = (typeof schema.const === 'string' && schema.const) ||
964
+ (typeof schema.default === 'string' && schema.default) ||
965
+ '';
966
+ if (value)
967
+ return value;
968
+ }
969
+ return undefined;
970
+ }
package/dist/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
2
2
  import { promisify } from 'node:util';
3
3
  const scrypt = promisify(scryptCallback);
4
4
  const KEY_LENGTH = 64;
@@ -41,28 +41,46 @@ export function validateAdminPassword(input) {
41
41
  }
42
42
  export class AdminSessionStore {
43
43
  ttlMs;
44
+ // Stored keys are SHA-256 digests of the raw session id so a timing attack
45
+ // on Map lookup / string comparison cannot learn a valid prefix. The raw
46
+ // id never leaves the HttpOnly cookie.
44
47
  sessions = new Map();
45
48
  constructor(ttlMs) {
46
49
  this.ttlMs = ttlMs;
47
50
  }
48
51
  create() {
49
52
  this.cleanup();
50
- const id = randomBytes(32).toString('base64url');
51
- this.sessions.set(id, Date.now() + this.ttlMs);
52
- return id;
53
+ const raw = randomBytes(32).toString('base64url');
54
+ this.sessions.set(digest(raw), Date.now() + this.ttlMs);
55
+ return raw;
53
56
  }
54
57
  has(id) {
55
- const expiresAt = this.sessions.get(id);
56
- if (!expiresAt)
58
+ if (typeof id !== 'string' || !id)
57
59
  return false;
60
+ const key = digest(id);
61
+ let match;
62
+ // Walk every stored digest in constant time. `timingSafeEqual` requires
63
+ // equal-length inputs, which is guaranteed because all digests have the
64
+ // same byte length.
65
+ for (const stored of this.sessions.keys()) {
66
+ const a = Buffer.from(stored, 'hex');
67
+ const b = Buffer.from(key, 'hex');
68
+ if (a.length === b.length && timingSafeEqual(a, b))
69
+ match = stored;
70
+ }
71
+ if (!match)
72
+ return false;
73
+ const expiresAt = this.sessions.get(match);
58
74
  if (expiresAt <= Date.now()) {
59
- this.sessions.delete(id);
75
+ this.sessions.delete(match);
60
76
  return false;
61
77
  }
62
78
  return true;
63
79
  }
64
80
  delete(id) {
65
- this.sessions.delete(id);
81
+ if (typeof id !== 'string' || !id)
82
+ return;
83
+ this.sessions.delete(digest(id));
66
84
  }
67
85
  clear() {
68
86
  this.sessions.clear();
@@ -75,3 +93,6 @@ export class AdminSessionStore {
75
93
  }
76
94
  }
77
95
  }
96
+ function digest(value) {
97
+ return createHash('sha256').update(value, 'utf8').digest('hex');
98
+ }
@@ -4,6 +4,6 @@ export const TURN_COMPLETION_CONTRACT = [
4
4
  'If you need user input or permission, use the protocol input/permission request and wait for the response.',
5
5
  'Before ending the turn, verify the requested work and provide a non-empty final response.',
6
6
  'The final response must summarize the outcome, verification performed, and workspace-relative paths for generated files.',
7
- 'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path>; Agent Nexus will snapshot that explicitly declared file as an artifact before the turn completes.',
7
+ 'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path>, where <path> stays inside the configured workspace roots; Agent Nexus snapshots that declared file as an artifact before the turn completes, refuses paths outside those roots, and attaches at most 8 files per turn.',
8
8
  '</agent_nexus_completion_contract>'
9
9
  ].join('\n');