nexus-agentd 0.2.2 → 0.2.5

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 CHANGED
@@ -62,22 +62,22 @@ Workspace 配置保持不变。
62
62
  默认值分别是 24 小时、30 分钟和 60 秒;A2A 请求超时在每个 Agent 的编辑页单独设置,默认 60 秒、
63
63
  最大 30 分钟。
64
64
 
65
- ## Agent 协议
66
-
67
- 每个任务都会附带协议级完成约束:Agent 在结束 turn 前必须处理完工作,等待用户输入或授权时必须使用
68
- 协议请求,并提供非空最终说明或 Artifact。Gateway 只接受以下完成证明:
69
-
70
- - ACP `session/prompt` 返回 `stopReason=end_turn`;`max_tokens`、`max_turn_requests`、拒绝和取消不会被
71
- 误报为成功。
72
- - A2A Task 明确进入 `COMPLETED`;如果远端直接返回 Message 而没有创建 Task,则以完整消息流结束作为
73
- turn 边界。已经出现 Task 状态但没有终态的流会标记失败。
74
- - Session 仍有待处理的 permission/input 请求,或者最终文本与 Artifact 都为空时,不允许进入
75
- `completed`。
76
-
77
- 成功的 Session 响应包含 `completion`,其中记录当前 Run ID、协议、完成来源、stop reason、最终文本和
78
- 产物存在性以及完成时间。这个证明验证的是协议边界和结果存在性,不替代业务内容本身的语义验收。
79
-
80
- ### ACP
65
+ ## Agent 协议
66
+
67
+ 每个任务都会附带协议级完成约束:Agent 在结束 turn 前必须处理完工作,等待用户输入或授权时必须使用
68
+ 协议请求,并提供非空最终说明或 Artifact。Gateway 只接受以下完成证明:
69
+
70
+ - ACP `session/prompt` 返回 `stopReason=end_turn`;`max_tokens`、`max_turn_requests`、拒绝和取消不会被
71
+ 误报为成功。
72
+ - A2A Task 明确进入 `COMPLETED`;如果远端直接返回 Message 而没有创建 Task,则以完整消息流结束作为
73
+ turn 边界。已经出现 Task 状态但没有终态的流会标记失败。
74
+ - Session 仍有待处理的 permission/input 请求,或者最终文本与 Artifact 都为空时,不允许进入
75
+ `completed`。
76
+
77
+ 成功的 Session 响应包含 `completion`,其中记录当前 Run ID、协议、完成来源、stop reason、最终文本和
78
+ 产物存在性以及完成时间。这个证明验证的是协议边界和结果存在性,不替代业务内容本身的语义验收。
79
+
80
+ ### ACP
81
81
 
82
82
  | Driver | 默认入口 |
83
83
  |---|---|
@@ -97,12 +97,12 @@ npm install -g \
97
97
  pi-acp
98
98
  ```
99
99
 
100
- `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
- 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
102
-
103
- ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
104
- Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
105
- 默认仍为 `ask`。
100
+ `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
101
+ 字段。Workspace 在启动进程前经过 `realpath` 边界校验。
102
+
103
+ ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
104
+ Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
105
+ 默认仍为 `ask`。
106
106
 
107
107
  ### A2A
108
108
 
@@ -150,14 +150,15 @@ A2A 使用官方 `@a2a-js/sdk` 客户端,通过完整的 Agent Card URL 发现
150
150
  }
151
151
  ```
152
152
 
153
- 输入附件通过 Session 临时保存,默认单个文件最多 16 MiB、单个 Session 最多 32 MiB、最多 16 个文件;
153
+ 输入附件通过 Session 临时保存,默认单个文件最多 16 MiB、单个 Session 最多 32 MiB、最多 16 个文件;
154
154
  HTTP 上传总上限由 `maxAttachmentBytes` 控制,默认 32 MiB,允许调整到 64 MiB。Session 释放时附件也会
155
155
  一起清理。ACP 会优先使用 Agent 声明支持的 image/audio/embeddedContext 能力,否则为 Agent 提供受限的
156
- `file://` resource link;A2A 则以带文件名和媒体类型的二进制 Part 发送。
157
-
158
- ACP Session 还支持显式发布工作区文件。发布接口只接受 realpath 仍位于该 Session 工作区中的普通文件,
159
- 拒绝目录、路径穿越和符号链接逃逸,单个文件最多 12 MiB。响应将文件作为 Session Artifact 返回;不会
160
- 暴露宿主机绝对路径。
156
+ `file://` resource link;A2A 则以带文件名和媒体类型的二进制 Part 发送。
157
+
158
+ ACP Session 还支持显式发布工作区文件。发布接口只接受 realpath 仍位于该 Session 工作区中的普通文件,
159
+ 拒绝目录、路径穿越和符号链接逃逸,单个文件最多 12 MiB。请求 body 支持单文件 `{ "path": "..." }` 或
160
+ 批量 `{ "paths": ["..."] }`(一次最多 32 条),响应在正常 Session 视图上附加
161
+ `publishedArtifacts` 数组,本次发布的文件以 base64 内联返回;不会暴露宿主机绝对路径,也不生成外部 URL。
161
162
 
162
163
  API Key 与 A2A 认证值支持 `env:VAR`。Console Password 哈希由 WebUI 管理,不要手工生成或把
163
164
  旧 `authToken` 复制到该字段。
@@ -200,27 +201,27 @@ POST /v1/admin/api-keys/:id/reveal
200
201
  POST /v1/admin/api-keys/:id/regenerate
201
202
  ```
202
203
 
203
- Bearer API Key 数据面:
204
-
205
- ```text
206
- GET /v1/meta
207
- GET /v1/agents
208
- POST /v1/sessions
209
- GET /v1/sessions/:id
210
- DELETE /v1/sessions/:id
211
- POST /v1/sessions/:id/attachments
212
- POST /v1/sessions/:id/message
213
- POST /v1/sessions/:id/requests/:requestId/resolve
214
- POST /v1/sessions/:id/artifacts/publish
215
- POST /v1/sessions/:id/cancel
216
- GET /v1/sessions/:id/events
217
- ```
218
-
219
- `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
220
- `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
221
- `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
222
- 释放 Agent 整个进程组并移除内存 Session。Gateway 停止时会先终止 Session,再在有限宽限期后关闭残留
223
- HTTP/SSE 连接,避免长连接或 Agent 孙进程阻塞服务重启。
204
+ Bearer API Key 数据面:
205
+
206
+ ```text
207
+ GET /v1/meta
208
+ GET /v1/agents
209
+ POST /v1/sessions
210
+ GET /v1/sessions/:id
211
+ DELETE /v1/sessions/:id
212
+ POST /v1/sessions/:id/attachments
213
+ POST /v1/sessions/:id/message
214
+ POST /v1/sessions/:id/requests/:requestId/resolve
215
+ POST /v1/sessions/:id/artifacts/publish
216
+ POST /v1/sessions/:id/cancel
217
+ GET /v1/sessions/:id/events
218
+ ```
219
+
220
+ `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
221
+ `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
222
+ `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
223
+ 释放 Agent 整个进程组并移除内存 Session。Gateway 停止时会先终止 Session,再在有限宽限期后关闭残留
224
+ HTTP/SSE 连接,避免长连接或 Agent 孙进程阻塞服务重启。
224
225
 
225
226
  API Key 的 Agent scope 在 Agent inventory、Session 创建和后续 Session 操作上都会检查;Session
226
227
  还绑定创建它的 Key,其他 Key 即使拥有同一 Agent scope 也不能读取或控制该 Session。
@@ -280,13 +280,16 @@ export class A2AClientRuntime {
280
280
  this.sink.clearPending();
281
281
  this.sink.setState('canceled');
282
282
  break;
283
- case TaskState.TASK_STATE_INPUT_REQUIRED:
283
+ case TaskState.TASK_STATE_INPUT_REQUIRED: {
284
+ const classification = a2aInputClassification(statusText, status.message);
284
285
  this.sink.setPending({
285
286
  id: this.taskId || randomUUID(),
286
287
  kind: 'input',
287
- prompt: statusText || 'The A2A agent requires additional input.'
288
+ prompt: statusText || 'The A2A agent requires additional input.',
289
+ ...classification
288
290
  });
289
291
  break;
292
+ }
290
293
  case TaskState.TASK_STATE_FAILED:
291
294
  case TaskState.TASK_STATE_REJECTED:
292
295
  case TaskState.TASK_STATE_AUTH_REQUIRED:
@@ -450,3 +453,37 @@ function disabledView(id, config) {
450
453
  function errorMessage(error) {
451
454
  return error instanceof Error ? error.message : String(error);
452
455
  }
456
+ /**
457
+ * Detect payment / confirmation / choice hints in an A2A `input_required`
458
+ * message so the Gateway can surface them on `pendingRequest.metadata` and
459
+ * `inputType`. The A2A protocol has no first-class field for these so we
460
+ * scan text parts for URLs and simple keywords.
461
+ */
462
+ function a2aInputClassification(statusText, message) {
463
+ const metadata = {};
464
+ const haystack = [
465
+ statusText,
466
+ ...(message?.parts ? textParts(message.parts) : [])
467
+ ]
468
+ .filter(Boolean)
469
+ .join('\n');
470
+ const urlMatch = haystack.match(/https?:\/\/\S+/);
471
+ if (urlMatch)
472
+ metadata.url = urlMatch[0];
473
+ let inputType = 'text';
474
+ if (/pay|支付|invoice|checkout|订单支付/i.test(haystack)) {
475
+ inputType = 'payment';
476
+ if (urlMatch)
477
+ metadata.paymentUrl = urlMatch[0];
478
+ }
479
+ else if (/[((]\s*[yY]\s*\/\s*[nN]\s*[))]|确认|是否/i.test(haystack)) {
480
+ inputType = 'confirmation';
481
+ }
482
+ else if (/(?:\n|^)\s*(?:\d+[.、]|[-*])\s+\S/.test(haystack)) {
483
+ inputType = 'choice';
484
+ }
485
+ return {
486
+ inputType,
487
+ ...(Object.keys(metadata).length ? { metadata } : {})
488
+ };
489
+ }
@@ -14,6 +14,8 @@ export declare class AcpProcessRuntime {
14
14
  private readonly promptTimeoutMs;
15
15
  private promptCapabilities;
16
16
  private inputDirectory?;
17
+ private workspace?;
18
+ private promptOutput;
17
19
  constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number);
18
20
  start(workspace: string): Promise<void>;
19
21
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
@@ -27,6 +29,7 @@ export declare class AcpProcessRuntime {
27
29
  private sessionUpdate;
28
30
  private captureToolArtifacts;
29
31
  private captureArtifact;
32
+ private captureMediaMarkers;
30
33
  private captureStderr;
31
34
  private onProcessFailure;
32
35
  private requireSessionId;
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
- import { pathToFileURL } from 'node:url';
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
10
  export class AcpProcessRuntime {
10
11
  driver;
11
12
  sink;
@@ -19,6 +20,8 @@ export class AcpProcessRuntime {
19
20
  promptTimeoutMs;
20
21
  promptCapabilities = {};
21
22
  inputDirectory;
23
+ workspace;
24
+ promptOutput = '';
22
25
  constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
23
26
  this.driver = driver;
24
27
  this.sink = sink;
@@ -31,6 +34,7 @@ export class AcpProcessRuntime {
31
34
  async start(workspace) {
32
35
  if (this.process)
33
36
  throw new Error('ACP runtime is already started');
37
+ this.workspace = workspace;
34
38
  this.inputDirectory = path.join(workspace, '.nexus-inputs', this.sink.id);
35
39
  const child = this.driver.spawn(workspace);
36
40
  this.process = child;
@@ -86,6 +90,7 @@ export class AcpProcessRuntime {
86
90
  throw new Error('ACP session is already processing a prompt');
87
91
  const sessionId = this.requireSessionId();
88
92
  this.prompting = true;
93
+ this.promptOutput = '';
89
94
  this.sink.clearPending();
90
95
  this.sink.setState('running');
91
96
  try {
@@ -98,6 +103,7 @@ export class AcpProcessRuntime {
98
103
  }
99
104
  switch (response.stopReason) {
100
105
  case 'end_turn':
106
+ await this.captureMediaMarkers();
101
107
  this.sink.completeTurn({
102
108
  source: 'acp_prompt_response',
103
109
  stopReason: response.stopReason
@@ -269,11 +275,16 @@ export class AcpProcessRuntime {
269
275
  kind: 'permission',
270
276
  prompt: params.toolCall.title ||
271
277
  `Permission requested for tool ${params.toolCall.toolCallId}`,
278
+ inputType: 'confirmation',
279
+ step: params.toolCall.toolCallId
280
+ ? `tool:${params.toolCall.toolCallId}`
281
+ : undefined,
272
282
  options: params.options.map((option) => ({
273
283
  id: option.optionId,
274
284
  name: option.name,
275
285
  kind: option.kind
276
- }))
286
+ })),
287
+ metadata: permissionMetadata(params)
277
288
  };
278
289
  this.sink.setPending(request);
279
290
  return new Promise((resolve, reject) => {
@@ -308,7 +319,8 @@ export class AcpProcessRuntime {
308
319
  prompt: params.mode === 'url'
309
320
  ? `${params.message}\n${params.url}`
310
321
  : params.message,
311
- options: elicitationOptions(params)
322
+ options: elicitationOptions(params),
323
+ ...elicitationClassification(params)
312
324
  };
313
325
  this.sink.setPending(request);
314
326
  return new Promise((resolve) => {
@@ -356,6 +368,7 @@ export class AcpProcessRuntime {
356
368
  switch (update.sessionUpdate) {
357
369
  case 'agent_message_chunk':
358
370
  if (update.content.type === 'text') {
371
+ this.promptOutput += update.content.text;
359
372
  this.sink.appendOutput(update.content.text);
360
373
  this.sink.emit('assistant_chunk', {
361
374
  messageId: update.messageId,
@@ -410,7 +423,35 @@ export class AcpProcessRuntime {
410
423
  if (artifact)
411
424
  this.sink.addArtifact(artifact);
412
425
  }
426
+ async captureMediaMarkers() {
427
+ const markers = mediaMarkers(this.promptOutput);
428
+ if (!markers.length)
429
+ return;
430
+ this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
431
+ const seen = new Set();
432
+ for (const marker of markers) {
433
+ if (seen.has(marker))
434
+ continue;
435
+ seen.add(marker);
436
+ try {
437
+ const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, this.workspace);
438
+ this.sink.addArtifact(artifact);
439
+ this.sink.emit('artifact', artifact);
440
+ }
441
+ catch (error) {
442
+ this.sink.emit('terminal_output', {
443
+ stream: 'system',
444
+ text: `Could not attach MEDIA artifact ${safeMarkerName(marker)}: ${error instanceof Error ? error.message : String(error)}`
445
+ });
446
+ }
447
+ }
448
+ }
413
449
  captureStderr(child) {
450
+ // Explicit UTF-8 streaming decoder: `String(chunk)` on a Buffer uses the
451
+ // default binary encoding, which mangles multi-byte characters if a
452
+ // chunk boundary splits a rune. `TextDecoder` with `stream: true`
453
+ // preserves the split byte between calls.
454
+ const decoder = new TextDecoder('utf-8');
414
455
  let buffer = '';
415
456
  let flushed = false;
416
457
  const emit = (text) => {
@@ -441,10 +482,16 @@ export class AcpProcessRuntime {
441
482
  if (flushed)
442
483
  return;
443
484
  flushed = true;
485
+ buffer += decoder.decode();
444
486
  drain(true);
445
487
  };
446
488
  child.stderr.on('data', (chunk) => {
447
- buffer += String(chunk);
489
+ const bytes = chunk instanceof Uint8Array
490
+ ? chunk
491
+ : Buffer.isBuffer(chunk)
492
+ ? chunk
493
+ : Buffer.from(String(chunk));
494
+ buffer += decoder.decode(bytes, { stream: true });
448
495
  drain();
449
496
  });
450
497
  child.stderr.once('end', flush);
@@ -619,6 +666,93 @@ function filenameFromValue(value) {
619
666
  function usableUrl(value) {
620
667
  return value && /^(?:https?|file):/i.test(value) ? value : undefined;
621
668
  }
669
+ function mediaMarkers(output) {
670
+ return output
671
+ .split(/\r?\n/)
672
+ .map((line) => line.match(/^\s*MEDIA:\s*(.+?)\s*$/i)?.[1]?.trim())
673
+ .filter((value) => Boolean(value));
674
+ }
675
+ function withoutMediaMarkers(output) {
676
+ return output
677
+ .split(/\r?\n/)
678
+ .filter((line) => !/^\s*MEDIA:\s*(.+?)\s*$/i.test(line))
679
+ .join('\n')
680
+ .trim();
681
+ }
682
+ async function artifactFromMediaMarker(marker, id, workspace) {
683
+ if (/^https?:\/\//i.test(marker)) {
684
+ const filename = artifactFilename(marker, undefined, 'resource', undefined);
685
+ return {
686
+ id,
687
+ name: filename,
688
+ filename,
689
+ url: marker,
690
+ metadata: { source: 'acp_media_marker' }
691
+ };
692
+ }
693
+ const localPath = marker.startsWith('file:')
694
+ ? fileURLToPath(marker)
695
+ : marker;
696
+ const resolved = await realpath(path.isAbsolute(localPath)
697
+ ? localPath
698
+ : path.resolve(workspace || process.cwd(), localPath));
699
+ const details = await stat(resolved);
700
+ if (!details.isFile()) {
701
+ throw new Error('declared path is not a regular file');
702
+ }
703
+ if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
704
+ throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
705
+ }
706
+ const filename = path.basename(resolved);
707
+ return {
708
+ id,
709
+ name: filename,
710
+ filename,
711
+ mediaType: mediaTypeForPath(filename),
712
+ bytesBase64: (await readFile(resolved)).toString('base64'),
713
+ metadata: {
714
+ source: 'acp_media_marker',
715
+ size: details.size
716
+ }
717
+ };
718
+ }
719
+ function safeMarkerName(marker) {
720
+ try {
721
+ return path.basename(marker.startsWith('file:') ? fileURLToPath(marker) : marker);
722
+ }
723
+ catch {
724
+ return 'declared artifact';
725
+ }
726
+ }
727
+ function mediaTypeForPath(value) {
728
+ switch (path.extname(value).toLowerCase()) {
729
+ case '.pptx':
730
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
731
+ case '.docx':
732
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
733
+ case '.xlsx':
734
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
735
+ case '.pdf':
736
+ return 'application/pdf';
737
+ case '.png':
738
+ return 'image/png';
739
+ case '.jpg':
740
+ case '.jpeg':
741
+ return 'image/jpeg';
742
+ case '.gif':
743
+ return 'image/gif';
744
+ case '.webp':
745
+ return 'image/webp';
746
+ case '.mp3':
747
+ return 'audio/mpeg';
748
+ case '.wav':
749
+ return 'audio/wav';
750
+ case '.mp4':
751
+ return 'video/mp4';
752
+ default:
753
+ return 'application/octet-stream';
754
+ }
755
+ }
622
756
  function artifactIndex(id) {
623
757
  return id.split(':').pop() || 'output';
624
758
  }
@@ -707,3 +841,88 @@ function isFormElicitation(params) {
707
841
  typeof params.requestedSchema ===
708
842
  'object'));
709
843
  }
844
+ /**
845
+ * Derive Agent Nexus semantic classification (inputType / step / metadata) from
846
+ * an ACP elicitation request. This is a best-effort projection: we look at the
847
+ * elicitation mode, the requested schema shape and well-known field names to
848
+ * decide whether the pending request is a plain text prompt, a choice, a
849
+ * yes/no confirmation, or a payment URL flow.
850
+ */
851
+ function elicitationClassification(params) {
852
+ const metadata = {};
853
+ let inputType = 'unknown';
854
+ let step;
855
+ if (params.mode === 'url') {
856
+ const url = String(params.url || '').trim();
857
+ if (url) {
858
+ metadata.paymentUrl = url;
859
+ metadata.url = url;
860
+ }
861
+ inputType = /pay|支付|invoice|order/i.test(`${params.message || ''} ${url}`)
862
+ ? 'payment'
863
+ : 'text';
864
+ step = 'url';
865
+ }
866
+ else if (isFormElicitation(params)) {
867
+ const properties = params.requestedSchema.properties || {};
868
+ const entries = Object.entries(properties);
869
+ step = entries.length === 1 ? entries[0][0] : 'form';
870
+ if (entries.length === 1) {
871
+ const [, schema] = entries[0];
872
+ if (schema.type === 'boolean')
873
+ inputType = 'confirmation';
874
+ else if (Array.isArray(schema.oneOf) ||
875
+ Array.isArray(schema.enum)) {
876
+ inputType = 'choice';
877
+ }
878
+ else {
879
+ inputType = 'text';
880
+ }
881
+ }
882
+ else {
883
+ inputType = 'text';
884
+ }
885
+ const paymentUrl = firstStringFromSchema(properties, [
886
+ 'paymentUrl',
887
+ 'payUrl',
888
+ 'payment_url',
889
+ 'checkoutUrl'
890
+ ]);
891
+ if (paymentUrl) {
892
+ metadata.paymentUrl = paymentUrl;
893
+ inputType = 'payment';
894
+ }
895
+ }
896
+ else {
897
+ inputType = 'text';
898
+ }
899
+ return {
900
+ inputType,
901
+ ...(step ? { step } : {}),
902
+ ...(Object.keys(metadata).length ? { metadata } : {})
903
+ };
904
+ }
905
+ function permissionMetadata(params) {
906
+ const metadata = {};
907
+ const call = params.toolCall;
908
+ if (typeof call?.toolCallId === 'string' && call.toolCallId) {
909
+ metadata.toolCallId = call.toolCallId;
910
+ }
911
+ if (typeof call?.name === 'string' && call.name) {
912
+ metadata.tool = call.name;
913
+ }
914
+ return Object.keys(metadata).length ? metadata : undefined;
915
+ }
916
+ function firstStringFromSchema(properties, candidates) {
917
+ for (const key of candidates) {
918
+ const schema = properties[key];
919
+ if (!schema)
920
+ continue;
921
+ const value = (typeof schema.const === 'string' && schema.const) ||
922
+ (typeof schema.default === 'string' && schema.default) ||
923
+ '';
924
+ if (value)
925
+ return value;
926
+ }
927
+ return undefined;
928
+ }
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.',
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.',
8
8
  '</agent_nexus_completion_contract>'
9
9
  ].join('\n');