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 +50 -49
- package/dist/a2a/runtime.js +39 -2
- package/dist/acp/runtime.d.ts +3 -0
- package/dist/acp/runtime.js +224 -5
- package/dist/auth.js +29 -8
- package/dist/completion-contract.js +1 -1
- package/dist/server.js +58 -8
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +9 -1
- package/dist/session.js +79 -22
- package/dist/types.d.ts +14 -0
- package/dist/webui/app/sources.d.ts +1 -0
- package/dist/webui/app/sources.js +17 -0
- package/dist/webui/icons.d.ts +17 -0
- package/dist/webui/icons.js +23 -6
- package/dist/webui/index.d.ts +1 -0
- package/dist/webui/index.js +32 -12
- package/dist/webui/markup.js +19 -18
- package/dist/webui/styles.js +601 -241
- package/package.json +52 -52
- package/dist/webui/app.d.ts +0 -1
- package/dist/webui/app.js +0 -914
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
|
|
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。
|
package/dist/a2a/runtime.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/acp/runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/acp/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
51
|
-
this.sessions.set(
|
|
52
|
-
return
|
|
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
|
-
|
|
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(
|
|
75
|
+
this.sessions.delete(match);
|
|
60
76
|
return false;
|
|
61
77
|
}
|
|
62
78
|
return true;
|
|
63
79
|
}
|
|
64
80
|
delete(id) {
|
|
65
|
-
|
|
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');
|