nexus-agentd 0.2.3 → 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.js +105 -3
- package/dist/auth.js +29 -8
- package/dist/server.js +58 -8
- package/dist/session.d.ts +8 -1
- package/dist/session.js +74 -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.js
CHANGED
|
@@ -275,11 +275,16 @@ export class AcpProcessRuntime {
|
|
|
275
275
|
kind: 'permission',
|
|
276
276
|
prompt: params.toolCall.title ||
|
|
277
277
|
`Permission requested for tool ${params.toolCall.toolCallId}`,
|
|
278
|
+
inputType: 'confirmation',
|
|
279
|
+
step: params.toolCall.toolCallId
|
|
280
|
+
? `tool:${params.toolCall.toolCallId}`
|
|
281
|
+
: undefined,
|
|
278
282
|
options: params.options.map((option) => ({
|
|
279
283
|
id: option.optionId,
|
|
280
284
|
name: option.name,
|
|
281
285
|
kind: option.kind
|
|
282
|
-
}))
|
|
286
|
+
})),
|
|
287
|
+
metadata: permissionMetadata(params)
|
|
283
288
|
};
|
|
284
289
|
this.sink.setPending(request);
|
|
285
290
|
return new Promise((resolve, reject) => {
|
|
@@ -314,7 +319,8 @@ export class AcpProcessRuntime {
|
|
|
314
319
|
prompt: params.mode === 'url'
|
|
315
320
|
? `${params.message}\n${params.url}`
|
|
316
321
|
: params.message,
|
|
317
|
-
options: elicitationOptions(params)
|
|
322
|
+
options: elicitationOptions(params),
|
|
323
|
+
...elicitationClassification(params)
|
|
318
324
|
};
|
|
319
325
|
this.sink.setPending(request);
|
|
320
326
|
return new Promise((resolve) => {
|
|
@@ -441,6 +447,11 @@ export class AcpProcessRuntime {
|
|
|
441
447
|
}
|
|
442
448
|
}
|
|
443
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');
|
|
444
455
|
let buffer = '';
|
|
445
456
|
let flushed = false;
|
|
446
457
|
const emit = (text) => {
|
|
@@ -471,10 +482,16 @@ export class AcpProcessRuntime {
|
|
|
471
482
|
if (flushed)
|
|
472
483
|
return;
|
|
473
484
|
flushed = true;
|
|
485
|
+
buffer += decoder.decode();
|
|
474
486
|
drain(true);
|
|
475
487
|
};
|
|
476
488
|
child.stderr.on('data', (chunk) => {
|
|
477
|
-
|
|
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 });
|
|
478
495
|
drain();
|
|
479
496
|
});
|
|
480
497
|
child.stderr.once('end', flush);
|
|
@@ -824,3 +841,88 @@ function isFormElicitation(params) {
|
|
|
824
841
|
typeof params.requestedSchema ===
|
|
825
842
|
'object'));
|
|
826
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
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import http from 'node:http';
|
|
|
3
3
|
import { AdminSessionStore } from './auth.js';
|
|
4
4
|
import { ControlPlaneError } from './control-plane.js';
|
|
5
5
|
import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
|
|
6
|
-
import { redirectToAgentdWebUi, writeAgentdWebUi } from './webui/index.js';
|
|
6
|
+
import { redirectToAgentdWebUi, writeAgentdWebUi, writeAgentdWebUiModule } from './webui/index.js';
|
|
7
7
|
const ADMIN_COOKIE = 'agent_nexus_admin';
|
|
8
8
|
export function createAgentdServer(config, sessions, controlPlane) {
|
|
9
9
|
const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
|
|
@@ -72,6 +72,10 @@ async function handleRequest(context) {
|
|
|
72
72
|
writeAgentdWebUi(response);
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
+
if (url.pathname.startsWith('/ui/') && request.method === 'GET') {
|
|
76
|
+
writeAgentdWebUiModule(response, url.pathname);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
75
79
|
if (url.pathname === '/health' && request.method === 'GET') {
|
|
76
80
|
writeJson(response, 200, {
|
|
77
81
|
ok: true,
|
|
@@ -220,8 +224,13 @@ async function handleRequest(context) {
|
|
|
220
224
|
assertAgentScope(principal, session.agentId);
|
|
221
225
|
assertJsonContentType(request);
|
|
222
226
|
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
223
|
-
assertOnlyKeys(body, ['path']);
|
|
224
|
-
|
|
227
|
+
assertOnlyKeys(body, ['path', 'paths']);
|
|
228
|
+
const paths = normalizePublishPaths(body);
|
|
229
|
+
const result = await sessions.publishFile(sessionId, paths);
|
|
230
|
+
writeJson(response, 201, {
|
|
231
|
+
...result.session,
|
|
232
|
+
publishedArtifacts: result.artifacts
|
|
233
|
+
});
|
|
225
234
|
return;
|
|
226
235
|
}
|
|
227
236
|
const match = url.pathname.match(/^\/v1\/sessions\/([^/]+)(?:\/(message|cancel|events))?$/);
|
|
@@ -456,11 +465,12 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
|
|
|
456
465
|
for (const event of sessions.eventsAfter(sessionId, after))
|
|
457
466
|
writeEvent(response, event);
|
|
458
467
|
const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
468
|
+
// Give idle SSE clients ~90 s to react before Node's default socket timeout
|
|
469
|
+
// trips. Combined with the writableEnded check inside the heartbeat this
|
|
470
|
+
// guarantees stuck keep-alive sessions eventually release their slot even
|
|
471
|
+
// if the peer never sends a FIN.
|
|
472
|
+
const idleMs = 90_000;
|
|
473
|
+
request.socket.setTimeout?.(idleMs);
|
|
464
474
|
let closed = false;
|
|
465
475
|
const close = () => {
|
|
466
476
|
if (closed)
|
|
@@ -468,10 +478,37 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
|
|
|
468
478
|
closed = true;
|
|
469
479
|
clearInterval(heartbeat);
|
|
470
480
|
unsubscribe();
|
|
481
|
+
// Ensure both ends of the socket are actually torn down so the SSE
|
|
482
|
+
// slot returns to the pool even when the peer stops reading without
|
|
483
|
+
// closing the TCP connection cleanly.
|
|
484
|
+
try {
|
|
485
|
+
if (!response.writableEnded)
|
|
486
|
+
response.end();
|
|
487
|
+
}
|
|
488
|
+
catch { }
|
|
489
|
+
try {
|
|
490
|
+
request.socket.destroy();
|
|
491
|
+
}
|
|
492
|
+
catch { }
|
|
471
493
|
release();
|
|
472
494
|
};
|
|
495
|
+
const heartbeat = setInterval(() => {
|
|
496
|
+
if (response.destroyed || response.writableEnded) {
|
|
497
|
+
close();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
response.write(': heartbeat\n\n');
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
close();
|
|
505
|
+
}
|
|
506
|
+
}, 15_000);
|
|
507
|
+
heartbeat.unref?.();
|
|
473
508
|
request.once('close', close);
|
|
474
509
|
response.once('close', close);
|
|
510
|
+
request.socket.once('timeout', close);
|
|
511
|
+
request.socket.once('error', close);
|
|
475
512
|
}
|
|
476
513
|
function writeEvent(response, event) {
|
|
477
514
|
if (response.destroyed || response.writableEnded)
|
|
@@ -644,6 +681,19 @@ function optionalAttachmentIds(value) {
|
|
|
644
681
|
}
|
|
645
682
|
return value.map((item) => requiredString(item, 'attachments'));
|
|
646
683
|
}
|
|
684
|
+
function normalizePublishPaths(body) {
|
|
685
|
+
const bodyPaths = body.paths;
|
|
686
|
+
if (bodyPaths !== undefined) {
|
|
687
|
+
if (!Array.isArray(bodyPaths) || bodyPaths.length === 0) {
|
|
688
|
+
throw new RequestError(400, 'paths must be a non-empty array of strings');
|
|
689
|
+
}
|
|
690
|
+
if (bodyPaths.length > 32) {
|
|
691
|
+
throw new RequestError(400, 'paths accepts at most 32 entries');
|
|
692
|
+
}
|
|
693
|
+
return bodyPaths.map((item) => requiredString(item, 'paths'));
|
|
694
|
+
}
|
|
695
|
+
return [requiredString(body.path, 'path')];
|
|
696
|
+
}
|
|
647
697
|
function normalizedMediaType(value) {
|
|
648
698
|
const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
|
|
649
699
|
return mediaType || undefined;
|
package/dist/session.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
|
|
|
37
37
|
replaceOutput(text: string): void;
|
|
38
38
|
addArtifact(artifact: AgentdArtifact): void;
|
|
39
39
|
publishFile(filePath: string): Promise<AgentdArtifact>;
|
|
40
|
+
publishFiles(filePaths: string[]): Promise<AgentdArtifact[]>;
|
|
41
|
+
private publishSingleFile;
|
|
40
42
|
setPending(request: AgentdPendingRequest): void;
|
|
41
43
|
clearPending(): void;
|
|
42
44
|
emit(type: AgentdEventType, data?: unknown): void;
|
|
@@ -58,6 +60,7 @@ export declare class SessionManager {
|
|
|
58
60
|
private readonly runStore?;
|
|
59
61
|
readonly instanceId: `${string}-${string}-${string}-${string}-${string}`;
|
|
60
62
|
private sessions;
|
|
63
|
+
private sessionLocks;
|
|
61
64
|
private cleanupTimer?;
|
|
62
65
|
private readinessCache?;
|
|
63
66
|
constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined);
|
|
@@ -76,12 +79,16 @@ export declare class SessionManager {
|
|
|
76
79
|
message(id: string, message: string, attachmentIds?: string[]): Promise<AgentdSessionView>;
|
|
77
80
|
resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
|
|
78
81
|
addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
|
|
79
|
-
publishFile(id: string,
|
|
82
|
+
publishFile(id: string, paths: string[]): Promise<{
|
|
83
|
+
session: AgentdSessionView;
|
|
84
|
+
artifacts: AgentdArtifact[];
|
|
85
|
+
}>;
|
|
80
86
|
cancel(id: string): Promise<AgentdSessionView>;
|
|
81
87
|
close(id: string): Promise<AgentdSessionView>;
|
|
82
88
|
eventsAfter(id: string, after?: string): AgentdEvent[];
|
|
83
89
|
subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
|
|
84
90
|
shutdown(): Promise<void>;
|
|
91
|
+
private withSessionLock;
|
|
85
92
|
private require;
|
|
86
93
|
private ensureCapacity;
|
|
87
94
|
private cleanup;
|