@ai_mind/dsh-a2a 1.0.0 → 1.1.1
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 +44 -11
- package/client.js +82 -23
- package/cordis.patch.yml +7 -2
- package/lib/config.js +11 -0
- package/lib/panel.js +40 -3
- package/lib/server.js +283 -83
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
把本机 DeepSeek Harness 智能体暴露为 **A2A(Agent2Agent)v1.0 服务端**的生产级插件。
|
|
4
4
|
|
|
5
|
-
协议实现基于官方 [`@a2a-js/sdk` 1.3.0](https://www.npmjs.com/package/@a2a-js/sdk)(A2A Protocol Specification v1.0 的官方 TypeScript SDK
|
|
5
|
+
协议实现基于官方 [`@a2a-js/sdk` 1.3.0](https://www.npmjs.com/package/@a2a-js/sdk)(A2A Protocol Specification v1.0 的官方 TypeScript SDK)。本插件默认复用 Harness Web 的监听端口,也可以配置 `server.port` 让 A2A 在独立端口监听,并把 A2A 任务桥接到真实的 Harness Agent:每个 A2A `contextId` 对应一个可复用的 Harness 会话,任务取消、超时、并发上限、沙箱与审批策略都由插件统一管理。
|
|
6
6
|
|
|
7
7
|
- 规范:[A2A Protocol v1.0.0](https://a2a-protocol.org/v1.0.0/specification/)
|
|
8
8
|
- 绑定:**JSON-RPC 2.0** + **HTTP+JSON/REST**(gRPC 未暴露,见[已知限制](#已知限制))
|
|
@@ -73,13 +73,13 @@
|
|
|
73
73
|
3. 验证 Agent Card:
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
curl -s http://127.0.0.1:
|
|
76
|
+
curl -s http://127.0.0.1:41241/.well-known/agent-card.json | jq .
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
4. 发一条消息(注意必须带 `A2A-Version: 1.0`):
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
curl -s http://127.0.0.1:
|
|
82
|
+
curl -s http://127.0.0.1:41241/a2a \
|
|
83
83
|
-H 'content-type: application/json' \
|
|
84
84
|
-H 'a2a-version: 1.0' \
|
|
85
85
|
-H "authorization: Bearer $A2A_AUTH_TOKEN" \
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
5. 流式调用:
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
|
-
curl -N http://127.0.0.1:
|
|
103
|
+
curl -N http://127.0.0.1:41241/a2a \
|
|
104
104
|
-H 'content-type: application/json' \
|
|
105
105
|
-H 'a2a-version: 1.0' \
|
|
106
106
|
-d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"m-2","role":"ROLE_USER","parts":[{"text":"列出当前目录的文件"}]}}}'
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
2. **配置表单**:直接编辑名称、描述、对外地址、挂载路径、流式/推送/扩展卡片、鉴权模式与令牌环境变量、访问令牌(写入后不再回显)、Agent 预设/模型/工作目录/沙箱/审批/并发/超时,以及请求体与任务保留限额。
|
|
119
119
|
3. **保存**:写入 profile 的 `cordis.patch.yml`(`a2a-server` 行),校验通过后插件自动重载;修改挂载路径后刷新页面即可。
|
|
120
120
|
|
|
121
|
-
配置接口 `GET/PUT <mountPath>/panel/config`
|
|
121
|
+
配置接口 `GET/PUT <mountPath>/panel/config` 始终位于 DSH Web 的 origin(独立端口模式下也不在 A2A 端口上),使用 **Harness Web 连接凭据**(不是 A2A token)鉴权:只有能打开 Settings 的已登录浏览器可以读写,远程 A2A 客户端即使知道路径也会收到 401。令牌字段是只写字段,GET 只返回“是否已配置”。
|
|
122
122
|
|
|
123
123
|
`lib/client-discovery.js` 会在插件挂载时清掉 DSH client 模块扫描器对“这个包没有浏览器半”的进程级缓存并触发一次重新扫描,因此从旧版本热升级后无需重启即可看到面板;全新启动时该逻辑自动跳过。
|
|
124
124
|
|
|
@@ -129,13 +129,13 @@ A2A 客户端必须发送 `A2A-Version: 1.0`,并带 `Content-Type: application
|
|
|
129
129
|
### 1. 发现 Agent Card
|
|
130
130
|
|
|
131
131
|
```bash
|
|
132
|
-
curl -s http://127.0.0.1:
|
|
132
|
+
curl -s http://127.0.0.1:41241/.well-known/agent-card.json | jq .
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
### 2. JSON-RPC 发送消息(阻塞)
|
|
136
136
|
|
|
137
137
|
```bash
|
|
138
|
-
curl -s http://127.0.0.1:
|
|
138
|
+
curl -s http://127.0.0.1:41241/a2a \
|
|
139
139
|
-H 'content-type: application/json' \
|
|
140
140
|
-H 'a2a-version: 1.0' \
|
|
141
141
|
-d '{
|
|
@@ -155,7 +155,7 @@ curl -s http://127.0.0.1:57133/a2a \
|
|
|
155
155
|
### 3. 流式(SSE)
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
|
-
curl -N http://127.0.0.1:
|
|
158
|
+
curl -N http://127.0.0.1:41241/a2a \
|
|
159
159
|
-H 'content-type: application/json' \
|
|
160
160
|
-H 'a2a-version: 1.0' \
|
|
161
161
|
-d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"m-2","role":"ROLE_USER","parts":[{"text":"列出当前目录的文件"}]}}}'
|
|
@@ -170,7 +170,7 @@ npm install @a2a-js/sdk
|
|
|
170
170
|
```js
|
|
171
171
|
import { ClientFactory, Role } from '@a2a-js/sdk/client'
|
|
172
172
|
|
|
173
|
-
const client = await new ClientFactory().createFromUrl('http://127.0.0.1:
|
|
173
|
+
const client = await new ClientFactory().createFromUrl('http://127.0.0.1:41241')
|
|
174
174
|
const result = await client.sendMessage({
|
|
175
175
|
message: {
|
|
176
176
|
messageId: crypto.randomUUID(),
|
|
@@ -186,7 +186,7 @@ console.log(result)
|
|
|
186
186
|
### 5. REST
|
|
187
187
|
|
|
188
188
|
```bash
|
|
189
|
-
curl -s http://127.0.0.1:
|
|
189
|
+
curl -s http://127.0.0.1:41241/a2a/rest/message:send \
|
|
190
190
|
-H 'content-type: application/a2a+json' \
|
|
191
191
|
-H 'a2a-version: 1.0' \
|
|
192
192
|
-d '{"message":{"messageId":"m-3","role":"ROLE_USER","parts":[{"text":"你好"}]}}' | jq .
|
|
@@ -228,6 +228,16 @@ JSON-RPC 方法名:`SendMessage`、`SendStreamingMessage`、`GetTask`、`ListT
|
|
|
228
228
|
|
|
229
229
|
凭据来源(可组合):`auth.clients[{id,token}]`、`auth.tokens[]`、`auth.token`、`auth.tokenEnv`(环境变量,支持逗号/换行分隔多个)。启用鉴权后 Agent Card 会自动声明 `securitySchemes` 与 `securityRequirements`。
|
|
230
230
|
|
|
231
|
+
内联令牌可以直接写在插件配置里(设置页的「A2A 访问令牌(内联)」字段等价):
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
auth:
|
|
235
|
+
mode: bearer
|
|
236
|
+
token: 'your-long-random-token'
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
> 在设置页输入令牌时,如果 `auth.mode` 还是 `none`,保存会自动切换为 `bearer`。令牌不会回显;它会以明文保存在 profile 的 `cordis.patch.yml`(权限 600),生产环境建议改用 `auth.tokenEnv` 或 `auth.clients`。
|
|
240
|
+
|
|
231
241
|
**每个凭据是一个独立的租户边界**:任务与 Push 配置按凭据 id 隔离,客户端 A 看不到客户端 B 的任务。未鉴权时所有 loopback 调用方共享 `anonymous` 作用域。
|
|
232
242
|
|
|
233
243
|
### 默认安全姿态
|
|
@@ -285,6 +295,27 @@ agent:
|
|
|
285
295
|
|
|
286
296
|
---
|
|
287
297
|
|
|
298
|
+
## 独立端口(不与 DSH 复用)
|
|
299
|
+
|
|
300
|
+
插件配置的默认值是 `server.port: null`:A2A 直接复用 DSH Web 的端口,Agent Card、JSON-RPC、REST 和设置面板共享同一个 origin。本仓库的 bundle patch 为了方便独立部署,默认设为 `127.0.0.1:41241`。需要显式控制时:
|
|
301
|
+
|
|
302
|
+
```yaml
|
|
303
|
+
server:
|
|
304
|
+
host: 127.0.0.1
|
|
305
|
+
port: 41241
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
此时:
|
|
309
|
+
|
|
310
|
+
- A2A 在 `http://127.0.0.1:41241/a2a` 单独监听(REST 为 `/a2a/rest`),Agent Card 和 `health` 也在该端口;
|
|
311
|
+
- DSH Web 端口只保留卡片镜像 `/.well-known/agent-card.json`(供设置页发现)和 `<mountPath>/panel` 配置面板(仍使用 GUI 登录凭据,不暴露在 A2A 端口上);
|
|
312
|
+
- Agent Card 的 `supportedInterfaces` 使用 `http://host:port`;如果前面有反向代理/TLS 终止,设置 `server.publicUrl` 为外部地址;
|
|
313
|
+
- 独立监听本身是 HTTP;公网部署时由反向代理终止 TLS。
|
|
314
|
+
|
|
315
|
+
本仓库的 `cordis.patch.yml` 默认使用 `127.0.0.1:41241`;修改 `server.port`(或在设置页的“监听端口”保存)后插件会重载并切换监听。若端口被占用,设置页会显示监听错误。
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
288
319
|
## 配置参考
|
|
289
320
|
|
|
290
321
|
完整默认值见 [`lib/config.js`](lib/config.js)。所有字段均可在 bundle 行 `config` 中覆盖;未知字段会令插件激活失败(避免拼写错误静默生效)。
|
|
@@ -295,7 +326,9 @@ enabled: true
|
|
|
295
326
|
server:
|
|
296
327
|
mountPath: /a2a
|
|
297
328
|
cardPath: /.well-known/agent-card.json
|
|
298
|
-
publicUrl: null # 对外可达的 origin
|
|
329
|
+
publicUrl: null # 对外可达的 origin;独立端口时默认 http://host:port
|
|
330
|
+
host: 127.0.0.1 # 独立监听的绑定地址(仅 server.port 非 null 时生效)
|
|
331
|
+
port: null # null = 复用 DSH Web 端口;1-65535 = 独立监听
|
|
299
332
|
trustProxy: false # 反向代理后设为 true,以使用 X-Forwarded-For
|
|
300
333
|
streaming: true
|
|
301
334
|
pushNotifications: true
|
package/client.js
CHANGED
|
@@ -40,6 +40,16 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
authOn: '已启用',
|
|
41
41
|
version: '协议版本',
|
|
42
42
|
runtime: '运行状态',
|
|
43
|
+
serverStatus: '监听方式',
|
|
44
|
+
serverMode: '模式',
|
|
45
|
+
serverModeShared: '复用 DSH 端口',
|
|
46
|
+
serverModeStandalone: '独立端口',
|
|
47
|
+
serverAddress: '地址',
|
|
48
|
+
serverListening: '监听中',
|
|
49
|
+
serverListeningYes: '是',
|
|
50
|
+
serverListeningNo: '否',
|
|
51
|
+
serverError: '监听错误',
|
|
52
|
+
serverNotListening: '独立端口未监听成功:请检查端口是否被占用,或修改 server.port。',
|
|
43
53
|
runtimeDenied: '运行状态需要 A2A 凭据,当前页面无权读取;这不影响其他信息。',
|
|
44
54
|
tasks: '任务',
|
|
45
55
|
contexts: '活跃上下文',
|
|
@@ -62,11 +72,11 @@ window.__ModuleLoader__.load({
|
|
|
62
72
|
saveError: '保存失败',
|
|
63
73
|
reload: '重新读取',
|
|
64
74
|
dirty: '有未保存的修改',
|
|
65
|
-
token: '
|
|
66
|
-
tokenHint: '
|
|
67
|
-
tokenPlaceholder: '
|
|
75
|
+
token: 'A2A 访问令牌(内联)',
|
|
76
|
+
tokenHint: '保存后写入插件配置的 auth.token;如果鉴权模式为 none,会自动切换为 bearer。值不会回显。',
|
|
77
|
+
tokenPlaceholder: '输入新的访问令牌',
|
|
68
78
|
clearToken: '清除已保存的内联令牌',
|
|
69
|
-
tokenConfigured: '
|
|
79
|
+
tokenConfigured: '已配置内联令牌(值不会回显)',
|
|
70
80
|
tokenNotConfigured: '未配置内联令牌',
|
|
71
81
|
tokenEnvConfigured: '环境变量已配置:{name}',
|
|
72
82
|
openCard: '打开 Agent Card',
|
|
@@ -83,6 +93,8 @@ window.__ModuleLoader__.load({
|
|
|
83
93
|
fieldCardDescription: '描述',
|
|
84
94
|
fieldCardVersion: '版本',
|
|
85
95
|
fieldServerPublicUrl: '对外地址',
|
|
96
|
+
fieldServerHost: '监听地址',
|
|
97
|
+
fieldServerPort: '监听端口',
|
|
86
98
|
fieldServerMountPath: '挂载路径',
|
|
87
99
|
fieldServerStreaming: '流式 SSE',
|
|
88
100
|
fieldServerPushNotifications: '推送通知',
|
|
@@ -139,6 +151,16 @@ window.__ModuleLoader__.load({
|
|
|
139
151
|
authOn: 'Required',
|
|
140
152
|
version: 'Protocol version',
|
|
141
153
|
runtime: 'Runtime',
|
|
154
|
+
serverStatus: 'Listener',
|
|
155
|
+
serverMode: 'Mode',
|
|
156
|
+
serverModeShared: 'Shared with DSH',
|
|
157
|
+
serverModeStandalone: 'Dedicated port',
|
|
158
|
+
serverAddress: 'Address',
|
|
159
|
+
serverListening: 'Listening',
|
|
160
|
+
serverListeningYes: 'yes',
|
|
161
|
+
serverListeningNo: 'no',
|
|
162
|
+
serverError: 'Listener error',
|
|
163
|
+
serverNotListening: 'The dedicated listener is not up. Check whether the port is taken or change server.port.',
|
|
142
164
|
runtimeDenied: 'Runtime counters require A2A credentials; the panel cannot read them. Everything else is unaffected.',
|
|
143
165
|
tasks: 'Tasks',
|
|
144
166
|
contexts: 'Active contexts',
|
|
@@ -161,11 +183,11 @@ window.__ModuleLoader__.load({
|
|
|
161
183
|
saveError: 'Save failed',
|
|
162
184
|
reload: 'Reload',
|
|
163
185
|
dirty: 'Unsaved changes',
|
|
164
|
-
token: '
|
|
165
|
-
tokenHint: '
|
|
166
|
-
tokenPlaceholder: '
|
|
186
|
+
token: 'A2A access token (inline)',
|
|
187
|
+
tokenHint: 'Saved as auth.token in the plugin config. A mode of "none" switches to bearer automatically. The value is never echoed.',
|
|
188
|
+
tokenPlaceholder: 'Enter a new access token',
|
|
167
189
|
clearToken: 'Clear the saved inline token',
|
|
168
|
-
tokenConfigured: 'Inline token configured',
|
|
190
|
+
tokenConfigured: 'Inline token configured (value is never shown)',
|
|
169
191
|
tokenNotConfigured: 'No inline token',
|
|
170
192
|
tokenEnvConfigured: 'Environment variable: {name}',
|
|
171
193
|
openCard: 'Open Agent Card',
|
|
@@ -182,6 +204,8 @@ window.__ModuleLoader__.load({
|
|
|
182
204
|
fieldCardDescription: 'Description',
|
|
183
205
|
fieldCardVersion: 'Version',
|
|
184
206
|
fieldServerPublicUrl: 'Public URL',
|
|
207
|
+
fieldServerHost: 'Listen host',
|
|
208
|
+
fieldServerPort: 'Listen port',
|
|
185
209
|
fieldServerMountPath: 'Mount path',
|
|
186
210
|
fieldServerStreaming: 'Streaming SSE',
|
|
187
211
|
fieldServerPushNotifications: 'Push notifications',
|
|
@@ -427,15 +451,14 @@ window.__ModuleLoader__.load({
|
|
|
427
451
|
if (jsonrpc && typeof jsonrpc.url === 'string') {
|
|
428
452
|
try {
|
|
429
453
|
const url = new URL(jsonrpc.url, window.location.origin)
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
panelPath = `${base}/panel/config`
|
|
454
|
+
const pathname = url.pathname.replace(/\/$/, '')
|
|
455
|
+
// The A2A port owns health; the panel stays on the GUI origin
|
|
456
|
+
// so the settings form reuses the browser's Harness session.
|
|
457
|
+
const endpointBase = `${url.origin}${pathname}`
|
|
458
|
+
const panelBase = `${window.location.origin}${pathname}`
|
|
459
|
+
// Prefer the same-origin panel: it carries the health snapshot
|
|
460
|
+
// and avoids a cross-origin read on the dedicated A2A port.
|
|
461
|
+
panelPath = `${panelBase}/panel/config`
|
|
439
462
|
try {
|
|
440
463
|
const panelResponse = await fetch(panelPath, { cache: 'no-store', signal: controller.signal, credentials: 'same-origin' })
|
|
441
464
|
if (panelResponse.ok) panel = await panelResponse.json()
|
|
@@ -445,6 +468,17 @@ window.__ModuleLoader__.load({
|
|
|
445
468
|
} catch {
|
|
446
469
|
panelError = 'error'
|
|
447
470
|
}
|
|
471
|
+
if (panel && panel.health) {
|
|
472
|
+
health = panel.health
|
|
473
|
+
} else {
|
|
474
|
+
try {
|
|
475
|
+
const healthResponse = await fetch(`${endpointBase}/health`, { cache: 'no-store', signal: controller.signal })
|
|
476
|
+
if (healthResponse.ok) health = await healthResponse.json()
|
|
477
|
+
else denied = healthResponse.status === 401 || healthResponse.status === 403
|
|
478
|
+
} catch {
|
|
479
|
+
/* standalone listener down: the panel status reports it */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
448
482
|
} catch {
|
|
449
483
|
denied = true
|
|
450
484
|
}
|
|
@@ -619,7 +653,7 @@ window.__ModuleLoader__.load({
|
|
|
619
653
|
const secretBlock = panel
|
|
620
654
|
? h(
|
|
621
655
|
'div',
|
|
622
|
-
{ className: 'a2aui-group' },
|
|
656
|
+
{ className: 'a2aui-group', key: 'inline-token' },
|
|
623
657
|
h('h5', { className: 'a2aui-group-title' }, t('token')),
|
|
624
658
|
h(
|
|
625
659
|
'div',
|
|
@@ -670,8 +704,8 @@ window.__ModuleLoader__.load({
|
|
|
670
704
|
? h(
|
|
671
705
|
'div',
|
|
672
706
|
{ className: 'a2aui-form' },
|
|
673
|
-
...groups.
|
|
674
|
-
h(
|
|
707
|
+
...groups.flatMap(([group, fields]) => {
|
|
708
|
+
const node = h(
|
|
675
709
|
'div',
|
|
676
710
|
{ className: 'a2aui-group', key: group },
|
|
677
711
|
h('h5', { className: 'a2aui-group-title' }, groupLabel(t, group)),
|
|
@@ -683,9 +717,9 @@ window.__ModuleLoader__.load({
|
|
|
683
717
|
h('div', null, renderControl(field)),
|
|
684
718
|
),
|
|
685
719
|
),
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
|
|
720
|
+
)
|
|
721
|
+
return group === 'auth' ? [node, secretBlock] : [node]
|
|
722
|
+
}),
|
|
689
723
|
h(
|
|
690
724
|
'div',
|
|
691
725
|
{ className: 'a2aui-form-foot' },
|
|
@@ -761,6 +795,30 @@ window.__ModuleLoader__.load({
|
|
|
761
795
|
),
|
|
762
796
|
)
|
|
763
797
|
|
|
798
|
+
const serverInfo = (state.panel && state.panel.server) || (health && health.server) || null
|
|
799
|
+
const serverSection = serverInfo
|
|
800
|
+
? h(
|
|
801
|
+
'section',
|
|
802
|
+
{ className: 'a2aui-card' },
|
|
803
|
+
h('h4', { className: 'a2aui-section-title' }, t('serverStatus')),
|
|
804
|
+
h(
|
|
805
|
+
'div',
|
|
806
|
+
{ className: 'a2aui-facts' },
|
|
807
|
+
h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverMode')), h('span', { className: 'a2aui-fact-value' }, serverInfo.mode === 'standalone' ? t('serverModeStandalone') : t('serverModeShared'))),
|
|
808
|
+
serverInfo.mode === 'standalone'
|
|
809
|
+
? h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverAddress')), h('span', { className: 'a2aui-fact-value' }, `${serverInfo.host}:${serverInfo.port}`))
|
|
810
|
+
: null,
|
|
811
|
+
h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverListening')), h('span', { className: 'a2aui-fact-value' }, serverInfo.listening ? t('serverListeningYes') : t('serverListeningNo'))),
|
|
812
|
+
),
|
|
813
|
+
serverInfo.error
|
|
814
|
+
? h('p', { className: 'a2aui-status', 'data-tone': 'error' }, `${t('serverError')}: ${serverInfo.error}`)
|
|
815
|
+
: null,
|
|
816
|
+
serverInfo.mode === 'standalone' && serverInfo.listening === false
|
|
817
|
+
? h('p', { className: 'a2aui-status', 'data-tone': 'warn' }, t('serverNotListening'))
|
|
818
|
+
: null,
|
|
819
|
+
)
|
|
820
|
+
: null
|
|
821
|
+
|
|
764
822
|
const runtimeSection = health
|
|
765
823
|
? h(
|
|
766
824
|
'section',
|
|
@@ -830,6 +888,7 @@ window.__ModuleLoader__.load({
|
|
|
830
888
|
header,
|
|
831
889
|
statusSection,
|
|
832
890
|
endpointSection,
|
|
891
|
+
serverSection,
|
|
833
892
|
runtimeSection,
|
|
834
893
|
deniedSection,
|
|
835
894
|
configSection,
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# dsh-a2a bundle patch: mounts the A2A (Agent2Agent) v1.0 server row.
|
|
2
2
|
#
|
|
3
|
-
# Defaults below are safe for a loopback deployment.
|
|
4
|
-
#
|
|
3
|
+
# Defaults below are safe for a loopback deployment. The row listens on its
|
|
4
|
+
# own port (127.0.0.1:41241) so A2A traffic does not share the DSH Web port;
|
|
5
|
+
# set `server.port: null` to reuse the DSH listener again. To expose the agent
|
|
6
|
+
# to other hosts, set `server.publicUrl` and enable `auth` in the profile patch:
|
|
5
7
|
#
|
|
6
8
|
# - id: a2a-server
|
|
7
9
|
# name: '@ai_mind/dsh-a2a'
|
|
@@ -19,3 +21,6 @@
|
|
|
19
21
|
name: '@ai_mind/dsh-a2a'
|
|
20
22
|
config:
|
|
21
23
|
enabled: true
|
|
24
|
+
server:
|
|
25
|
+
host: 127.0.0.1
|
|
26
|
+
port: 41241
|
package/lib/config.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module @ai_mind/dsh-a2a/config
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isIP } from 'node:net'
|
|
12
13
|
import { isPlainObject, deepMerge, deepFreeze } from './util.js'
|
|
13
14
|
|
|
14
15
|
/** Error raised for an invalid plugin configuration. */
|
|
@@ -44,6 +45,8 @@ export const DEFAULT_CONFIG = {
|
|
|
44
45
|
mountPath: '/a2a',
|
|
45
46
|
cardPath: '/.well-known/agent-card.json',
|
|
46
47
|
publicUrl: null,
|
|
48
|
+
host: '127.0.0.1',
|
|
49
|
+
port: null,
|
|
47
50
|
trustProxy: false,
|
|
48
51
|
streaming: true,
|
|
49
52
|
pushNotifications: true,
|
|
@@ -350,6 +353,14 @@ export function normalizeConfig(raw) {
|
|
|
350
353
|
merged.server.mountPath = validatePath(merged.server.mountPath, 'server.mountPath', { reserved: true })
|
|
351
354
|
merged.server.cardPath = validatePath(merged.server.cardPath, 'server.cardPath')
|
|
352
355
|
merged.server.publicUrl = expectNullableString(merged.server.publicUrl, 'server.publicUrl', { allowEmpty: false })
|
|
356
|
+
merged.server.host = expectString(merged.server.host, 'server.host')
|
|
357
|
+
if (merged.server.host !== 'localhost' && isIP(merged.server.host) === 0) {
|
|
358
|
+
fail('server.host', 'must be an IPv4/IPv6 literal or "localhost"')
|
|
359
|
+
}
|
|
360
|
+
merged.server.port =
|
|
361
|
+
merged.server.port === null || merged.server.port === undefined
|
|
362
|
+
? null
|
|
363
|
+
: expectInteger(merged.server.port, 'server.port', { min: 1, max: 65535 })
|
|
353
364
|
if (merged.server.publicUrl !== null) {
|
|
354
365
|
let url
|
|
355
366
|
try {
|
package/lib/panel.js
CHANGED
|
@@ -30,6 +30,16 @@ const FIELDS = [
|
|
|
30
30
|
{ path: 'card.description', type: 'textarea', group: 'card' },
|
|
31
31
|
{ path: 'card.version', type: 'string', group: 'card' },
|
|
32
32
|
{ path: 'server.publicUrl', type: 'string', group: 'server', nullable: true, placeholder: 'https://agent.example.com' },
|
|
33
|
+
{ path: 'server.host', type: 'string', group: 'server', placeholder: '127.0.0.1' },
|
|
34
|
+
{
|
|
35
|
+
path: 'server.port',
|
|
36
|
+
type: 'integer',
|
|
37
|
+
group: 'server',
|
|
38
|
+
nullable: true,
|
|
39
|
+
min: 1,
|
|
40
|
+
max: 65535,
|
|
41
|
+
placeholder: '留空 = 复用 DSH 端口',
|
|
42
|
+
},
|
|
33
43
|
{ path: 'server.mountPath', type: 'string', group: 'server' },
|
|
34
44
|
{ path: 'server.streaming', type: 'boolean', group: 'server' },
|
|
35
45
|
{ path: 'server.pushNotifications', type: 'boolean', group: 'server' },
|
|
@@ -187,9 +197,20 @@ function describe(config, entry, warnings) {
|
|
|
187
197
|
* @param {Readonly<object>} options.config the active normalized config
|
|
188
198
|
* @param {string} options.mountPath A2A mount path (for messages)
|
|
189
199
|
* @param {{ info: Function, warn: Function }} options.logger
|
|
200
|
+
* @param {() => object} [options.status] live listening-mode description
|
|
201
|
+
* @param {() => object} [options.health] live health snapshot
|
|
190
202
|
* @returns {import('express').Router}
|
|
191
203
|
*/
|
|
192
|
-
|
|
204
|
+
function safeHealth(health, logger) {
|
|
205
|
+
try {
|
|
206
|
+
return health()
|
|
207
|
+
} catch (error) {
|
|
208
|
+
logger.warn(`[a2a] panel health snapshot failed: ${errorMessage(error)}`)
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createPanelRouter({ ctx, config, mountPath, logger, status, health }) {
|
|
193
214
|
const router = express.Router()
|
|
194
215
|
const json = express.json({ limit: 256 * 1024, strict: false })
|
|
195
216
|
|
|
@@ -263,7 +284,14 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
|
|
|
263
284
|
}
|
|
264
285
|
const payload = describe(live, entry, warnings)
|
|
265
286
|
res.setHeader('Cache-Control', 'no-store')
|
|
266
|
-
res.json({
|
|
287
|
+
res.json({
|
|
288
|
+
ok: true,
|
|
289
|
+
plugin: { name: PLUGIN_NAME, version: '1.1.1' },
|
|
290
|
+
mountPath,
|
|
291
|
+
...payload,
|
|
292
|
+
...(typeof status === 'function' ? { server: status() } : {}),
|
|
293
|
+
...(typeof health === 'function' ? { health: safeHealth(health, logger) } : {}),
|
|
294
|
+
})
|
|
267
295
|
})
|
|
268
296
|
|
|
269
297
|
router.put('/config', json, async (req, res) => {
|
|
@@ -314,6 +342,9 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
|
|
|
314
342
|
if (typeof body.token !== 'string') throw new Error('token must be a string')
|
|
315
343
|
if (body.token.trim() === '') throw new Error('token must not be empty')
|
|
316
344
|
setAt(next, 'auth.token', body.token)
|
|
345
|
+
// A token with auth.mode "none" would be stored but never used. Treat
|
|
346
|
+
// an explicitly supplied token as the intent to enable bearer auth.
|
|
347
|
+
if ((next.auth?.mode ?? 'none') === 'none') setAt(next, 'auth.mode', 'bearer')
|
|
317
348
|
}
|
|
318
349
|
|
|
319
350
|
const normalized = normalizeConfig(next)
|
|
@@ -350,7 +381,13 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
|
|
|
350
381
|
/* keep the submitted view when the row is mid-reload */
|
|
351
382
|
}
|
|
352
383
|
logger.info('[a2a] configuration updated from the settings panel')
|
|
353
|
-
res.json({
|
|
384
|
+
res.json({
|
|
385
|
+
ok: true,
|
|
386
|
+
plugin: { name: PLUGIN_NAME, version: '1.1.1' },
|
|
387
|
+
mountPath,
|
|
388
|
+
...describe(live, liveEntry, []),
|
|
389
|
+
...(typeof status === 'function' ? { server: status() } : {}),
|
|
390
|
+
})
|
|
354
391
|
})
|
|
355
392
|
|
|
356
393
|
return router
|
package/lib/server.js
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The official `@a2a-js/sdk` supplies the protocol engine — task lifecycle,
|
|
5
5
|
* JSON-RPC and HTTP+JSON transports, SSE framing, ProtoJSON codecs, push
|
|
6
|
-
* notifications and Agent Card signing.
|
|
7
|
-
*
|
|
6
|
+
* notifications and Agent Card signing.
|
|
7
|
+
*
|
|
8
|
+
* Two listening modes are supported:
|
|
9
|
+
* - shared (default): the A2A routes live on the Harness Web server, so the
|
|
10
|
+
* endpoint uses the GUI's port and lifecycle;
|
|
11
|
+
* - standalone (`server.port` set): a dedicated `node:http` listener owns the
|
|
12
|
+
* card and A2A routes on its own host/port, while the Harness Web server
|
|
13
|
+
* keeps only the public card mirror and the authenticated settings panel.
|
|
8
14
|
*
|
|
9
15
|
* @module @ai_mind/dsh-a2a/server
|
|
10
16
|
*/
|
|
11
17
|
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import { isIP } from 'node:net'
|
|
12
20
|
import express from 'express'
|
|
13
21
|
import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
|
|
14
22
|
import {
|
|
@@ -49,9 +57,31 @@ function createLogger(ctx) {
|
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
/**
|
|
60
|
+
/** Write an IPv6 host in URL form. */
|
|
61
|
+
function formatUrlHost(host) {
|
|
62
|
+
return isIP(host) === 6 ? `[${host}]` : host
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Derive the externally visible origin.
|
|
67
|
+
*
|
|
68
|
+
* `server.publicUrl` always wins. A configured `server.port` selects the
|
|
69
|
+
* standalone listener's own origin; otherwise the Harness Web server's origin
|
|
70
|
+
* is used.
|
|
71
|
+
*/
|
|
53
72
|
function resolvePublicBase(config, webServer, logger) {
|
|
54
73
|
if (config.server.publicUrl) return trimTrailingSlash(config.server.publicUrl)
|
|
74
|
+
if (config.server.port !== null) {
|
|
75
|
+
const host = config.server.host
|
|
76
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
77
|
+
logger.warn(
|
|
78
|
+
`[a2a] server.host ${host} is a wildcard; advertising a loopback URL. ` +
|
|
79
|
+
'Set server.publicUrl to the address remote A2A clients should use.',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
const advertised = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host
|
|
83
|
+
return `http://${formatUrlHost(advertised)}:${config.server.port}`
|
|
84
|
+
}
|
|
55
85
|
try {
|
|
56
86
|
const origin = webServer.getOrigin()
|
|
57
87
|
if (typeof origin === 'string' && origin !== '') return trimTrailingSlash(origin)
|
|
@@ -134,7 +164,8 @@ function writeError(req, res, { status, code, message, restBinding, rpcCode = -3
|
|
|
134
164
|
/**
|
|
135
165
|
* One mounted A2A deployment. Mounting is synchronous so route registration
|
|
136
166
|
* can participate in Cordis effects; only request handling touches async
|
|
137
|
-
* agent work.
|
|
167
|
+
* agent work. The standalone listener is created here and reports its bind
|
|
168
|
+
* result through {@link A2AService.serverStatus}.
|
|
138
169
|
*/
|
|
139
170
|
export class A2AService {
|
|
140
171
|
#ctx
|
|
@@ -142,6 +173,7 @@ export class A2AService {
|
|
|
142
173
|
#logger
|
|
143
174
|
#mounted = undefined
|
|
144
175
|
#unmount = undefined
|
|
176
|
+
#standalone = undefined
|
|
145
177
|
#disposed = false
|
|
146
178
|
|
|
147
179
|
/**
|
|
@@ -160,16 +192,50 @@ export class A2AService {
|
|
|
160
192
|
}
|
|
161
193
|
|
|
162
194
|
/**
|
|
163
|
-
*
|
|
195
|
+
* Describe the active listening mode for health checks and the settings
|
|
196
|
+
* panel. In shared mode the DSH web server owns the socket, so `listening`
|
|
197
|
+
* only means the plugin is mounted.
|
|
198
|
+
*
|
|
199
|
+
* @returns {{ mode: 'shared'|'standalone', host: string|null, port: number|null, listening: boolean, error: string|null, publicUrl: string|null }}
|
|
200
|
+
*/
|
|
201
|
+
serverStatus() {
|
|
202
|
+
const config = this.#config
|
|
203
|
+
const mounted = this.#mounted
|
|
204
|
+
if (config.server.port === null) {
|
|
205
|
+
return {
|
|
206
|
+
mode: 'shared',
|
|
207
|
+
host: null,
|
|
208
|
+
port: null,
|
|
209
|
+
listening: mounted !== undefined,
|
|
210
|
+
error: null,
|
|
211
|
+
publicUrl: mounted?.publicBaseUrl ?? null,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const state = this.#standalone
|
|
215
|
+
return {
|
|
216
|
+
mode: 'standalone',
|
|
217
|
+
host: config.server.host,
|
|
218
|
+
port: config.server.port,
|
|
219
|
+
listening: state?.listening === true,
|
|
220
|
+
error: state?.error ?? null,
|
|
221
|
+
publicUrl: mounted?.publicBaseUrl ?? null,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mount the A2A deployment.
|
|
164
227
|
*
|
|
165
228
|
* @param {object} webServer the `webServer` service
|
|
166
|
-
* @returns {() => Promise<void>} disposer that unregisters routes
|
|
229
|
+
* @returns {() => Promise<void>} disposer that unregisters routes, closes the
|
|
230
|
+
* standalone listener and drains work
|
|
167
231
|
*/
|
|
168
232
|
mount(webServer) {
|
|
169
233
|
if (this.#mounted !== undefined) throw new Error('a2a: already mounted')
|
|
170
234
|
if (this.#disposed) throw new Error('a2a: service is disposed')
|
|
171
235
|
const config = this.#config
|
|
172
236
|
const logger = this.#logger
|
|
237
|
+
const mountPath = config.server.mountPath
|
|
238
|
+
const standalone = config.server.port !== null
|
|
173
239
|
|
|
174
240
|
const credentials = resolveCredentials(config)
|
|
175
241
|
const publicBaseUrl = resolvePublicBase(config, webServer, logger)
|
|
@@ -224,84 +290,59 @@ export class A2AService {
|
|
|
224
290
|
config,
|
|
225
291
|
credentials,
|
|
226
292
|
logger,
|
|
227
|
-
isRestBinding: (req) => isRestBinding(req,
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
const app = express()
|
|
231
|
-
app.disable('x-powered-by')
|
|
232
|
-
app.set('trust proxy', config.server.trustProxy)
|
|
233
|
-
app.set('query parser', 'simple')
|
|
234
|
-
|
|
235
|
-
app.use(createCors(config))
|
|
236
|
-
app.use((req, res, next) => {
|
|
237
|
-
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
238
|
-
next()
|
|
293
|
+
isRestBinding: (req) => isRestBinding(req, mountPath),
|
|
239
294
|
})
|
|
240
|
-
if (config.server.logRequests) {
|
|
241
|
-
app.use((req, res, next) => {
|
|
242
|
-
const started = Date.now()
|
|
243
|
-
res.on('finish', () => {
|
|
244
|
-
logger.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started}ms)`)
|
|
245
|
-
})
|
|
246
|
-
next()
|
|
247
|
-
})
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Parse bodies once with the configured limit. The SDK transports skip
|
|
251
|
-
// their own `express.json()` because body-parser marks the request parsed.
|
|
252
|
-
app.use(
|
|
253
|
-
express.json({
|
|
254
|
-
limit: config.limits.maxRequestBytes,
|
|
255
|
-
strict: false,
|
|
256
|
-
type: [JSON_CONTENT_TYPE, A2A_CONTENT_TYPE],
|
|
257
|
-
}),
|
|
258
|
-
)
|
|
259
295
|
|
|
260
|
-
const mountPath = config.server.mountPath
|
|
261
|
-
const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
|
|
262
|
-
const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
|
|
263
296
|
const cardRouter = agentCardHandler({
|
|
264
297
|
agentCardProvider: requestHandler,
|
|
265
298
|
cache: { maxAge: config.server.cardCacheSeconds },
|
|
266
299
|
})
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
status: 405,
|
|
282
|
-
code: 'METHOD_NOT_ALLOWED',
|
|
283
|
-
message: `The A2A JSON-RPC endpoint only accepts POST; REST clients use ${mountPath}/rest.`,
|
|
284
|
-
restBinding: false,
|
|
285
|
-
})
|
|
300
|
+
const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
|
|
301
|
+
const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
|
|
302
|
+
const healthSnapshot = () =>
|
|
303
|
+
this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard })
|
|
304
|
+
const panelRouter = createPanelRouter({
|
|
305
|
+
ctx: this.#ctx,
|
|
306
|
+
config,
|
|
307
|
+
mountPath,
|
|
308
|
+
logger,
|
|
309
|
+
status: () => this.serverStatus(),
|
|
310
|
+
// The panel is same-origin with the GUI but possibly cross-origin to the
|
|
311
|
+
// A2A port; return the health snapshot here so the panel does not depend
|
|
312
|
+
// on CORS being enabled on the standalone listener.
|
|
313
|
+
health: healthSnapshot,
|
|
286
314
|
})
|
|
287
315
|
|
|
288
|
-
|
|
289
|
-
app.
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
const applyCommon = (app) => {
|
|
317
|
+
app.disable('x-powered-by')
|
|
318
|
+
app.set('trust proxy', config.server.trustProxy)
|
|
319
|
+
app.set('query parser', 'simple')
|
|
320
|
+
app.use(createCors(config))
|
|
321
|
+
app.use((req, res, next) => {
|
|
322
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
323
|
+
next()
|
|
292
324
|
})
|
|
325
|
+
if (config.server.logRequests) {
|
|
326
|
+
app.use((req, res, next) => {
|
|
327
|
+
const started = Date.now()
|
|
328
|
+
res.on('finish', () => {
|
|
329
|
+
logger.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started}ms)`)
|
|
330
|
+
})
|
|
331
|
+
next()
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
// Parse bodies once with the configured limit. The SDK transports skip
|
|
335
|
+
// their own `express.json()` because body-parser marks the request parsed.
|
|
336
|
+
app.use(
|
|
337
|
+
express.json({
|
|
338
|
+
limit: config.limits.maxRequestBytes,
|
|
339
|
+
strict: false,
|
|
340
|
+
type: [JSON_CONTENT_TYPE, A2A_CONTENT_TYPE],
|
|
341
|
+
}),
|
|
342
|
+
)
|
|
293
343
|
}
|
|
294
344
|
|
|
295
|
-
|
|
296
|
-
writeError(req, res, {
|
|
297
|
-
status: 404,
|
|
298
|
-
code: 'NOT_FOUND',
|
|
299
|
-
message: `No A2A route matches ${req.method} ${req.originalUrl}`,
|
|
300
|
-
restBinding: isRestBinding(req, mountPath),
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
app.use((error, req, res, next) => {
|
|
345
|
+
const a2aErrorHandler = (error, req, res, next) => {
|
|
305
346
|
if (res.headersSent) {
|
|
306
347
|
res.end()
|
|
307
348
|
return
|
|
@@ -324,9 +365,67 @@ export class A2AService {
|
|
|
324
365
|
rpcCode: isParseError ? -32700 : isTooLarge ? -32600 : -32603,
|
|
325
366
|
})
|
|
326
367
|
void next
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const panelErrorHandler = (error, req, res, next) => {
|
|
371
|
+
if (res.headersSent) {
|
|
372
|
+
res.end()
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
const status =
|
|
376
|
+
typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
|
|
377
|
+
if (status >= 500) logger.warn(`[a2a] panel request failed: ${errorMessage(error)}`)
|
|
378
|
+
res.statusCode = status
|
|
379
|
+
res.setHeader('Content-Type', JSON_CONTENT_TYPE)
|
|
380
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
381
|
+
res.end(
|
|
382
|
+
JSON.stringify({
|
|
383
|
+
error: status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
|
|
384
|
+
message: error?.type === 'entity.parse.failed' ? 'Invalid JSON payload' : errorMessage(error),
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
void next
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const a2aApp = express()
|
|
391
|
+
applyCommon(a2aApp)
|
|
392
|
+
// The Agent Card is discovery metadata and stays public.
|
|
393
|
+
a2aApp.use(config.server.cardPath, cardRouter)
|
|
394
|
+
if (!standalone) {
|
|
395
|
+
// In shared mode the panel is part of the same app and must precede the
|
|
396
|
+
// A2A auth middleware.
|
|
397
|
+
a2aApp.use(`${mountPath}/panel`, panelRouter)
|
|
398
|
+
}
|
|
399
|
+
// Everything below the mount path is the A2A endpoint.
|
|
400
|
+
a2aApp.use(mountPath, authenticator.middleware)
|
|
401
|
+
a2aApp.use(`${mountPath}/rest`, restRouter)
|
|
402
|
+
a2aApp.use(mountPath, jsonRpcRouter)
|
|
403
|
+
a2aApp.get(mountPath, (req, res) => {
|
|
404
|
+
res.setHeader('Allow', 'POST')
|
|
405
|
+
writeError(req, res, {
|
|
406
|
+
status: 405,
|
|
407
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
408
|
+
message: `The A2A JSON-RPC endpoint only accepts POST; REST clients use ${mountPath}/rest.`,
|
|
409
|
+
restBinding: false,
|
|
410
|
+
})
|
|
327
411
|
})
|
|
412
|
+
if (config.server.health) {
|
|
413
|
+
a2aApp.get(`${mountPath}/health`, (req, res) => {
|
|
414
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
415
|
+
res.json(healthSnapshot())
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
a2aApp.use((req, res) => {
|
|
419
|
+
writeError(req, res, {
|
|
420
|
+
status: 404,
|
|
421
|
+
code: 'NOT_FOUND',
|
|
422
|
+
message: `No A2A route matches ${req.method} ${req.originalUrl}`,
|
|
423
|
+
restBinding: isRestBinding(req, mountPath),
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
a2aApp.use(a2aErrorHandler)
|
|
328
427
|
|
|
329
|
-
const
|
|
428
|
+
const wrap = (app) => (req, res) => {
|
|
330
429
|
try {
|
|
331
430
|
app(req, res)
|
|
332
431
|
} catch (error) {
|
|
@@ -340,11 +439,30 @@ export class A2AService {
|
|
|
340
439
|
}
|
|
341
440
|
}
|
|
342
441
|
}
|
|
442
|
+
const callA2A = wrap(a2aApp)
|
|
443
|
+
|
|
444
|
+
let callPanel
|
|
445
|
+
if (standalone) {
|
|
446
|
+
// The settings panel stays on the Harness Web origin so it can reuse the
|
|
447
|
+
// GUI's authenticated browser session. It is not exposed on the A2A port.
|
|
448
|
+
const panelApp = express()
|
|
449
|
+
panelApp.disable('x-powered-by')
|
|
450
|
+
panelApp.set('trust proxy', config.server.trustProxy)
|
|
451
|
+
panelApp.use(`${mountPath}/panel`, panelRouter)
|
|
452
|
+
panelApp.use((req, res) => {
|
|
453
|
+
res.statusCode = 404
|
|
454
|
+
res.setHeader('Content-Type', JSON_CONTENT_TYPE)
|
|
455
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `No settings panel route matches ${req.url}` }))
|
|
456
|
+
})
|
|
457
|
+
panelApp.use(panelErrorHandler)
|
|
458
|
+
callPanel = wrap(panelApp)
|
|
459
|
+
}
|
|
343
460
|
|
|
344
461
|
const routeDisposers = []
|
|
345
|
-
|
|
462
|
+
let standaloneState
|
|
463
|
+
const registerRoute = (route, handler, label, { optional = false } = {}) => {
|
|
346
464
|
try {
|
|
347
|
-
routeDisposers.push(webServer.register({ ...route, handler
|
|
465
|
+
routeDisposers.push(webServer.register({ ...route, handler }))
|
|
348
466
|
} catch (error) {
|
|
349
467
|
if (optional) {
|
|
350
468
|
logger.warn(`[a2a] cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
|
|
@@ -354,8 +472,70 @@ export class A2AService {
|
|
|
354
472
|
}
|
|
355
473
|
}
|
|
356
474
|
|
|
357
|
-
|
|
358
|
-
|
|
475
|
+
const cleanupAfterFailure = () => {
|
|
476
|
+
for (const dispose of routeDisposers.splice(0).reverse()) {
|
|
477
|
+
try {
|
|
478
|
+
dispose()
|
|
479
|
+
} catch {
|
|
480
|
+
/* ignore during rollback */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (standaloneState !== undefined) {
|
|
484
|
+
standaloneState.server.close()
|
|
485
|
+
standaloneState.server.closeAllConnections?.()
|
|
486
|
+
this.#standalone = undefined
|
|
487
|
+
}
|
|
488
|
+
void executor.dispose().catch((error) => logger.warn(`[a2a] rollback dispose failed: ${errorMessage(error)}`))
|
|
489
|
+
void runtime.shutdown().catch((error) => logger.warn(`[a2a] rollback shutdown failed: ${errorMessage(error)}`))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
// Discovery stays reachable on the Harness Web origin in both modes: in
|
|
494
|
+
// shared mode it is the endpoint, and in standalone mode it bootstraps
|
|
495
|
+
// the settings panel (the card itself points at the dedicated port).
|
|
496
|
+
registerRoute({ kind: 'exact', path: config.server.cardPath }, callA2A, 'agent card')
|
|
497
|
+
if (standalone) {
|
|
498
|
+
registerRoute(
|
|
499
|
+
{ kind: 'prefix', path: `${mountPath}/panel` },
|
|
500
|
+
callPanel,
|
|
501
|
+
'settings panel',
|
|
502
|
+
)
|
|
503
|
+
const server = http.createServer(callA2A)
|
|
504
|
+
standaloneState = {
|
|
505
|
+
server,
|
|
506
|
+
host: config.server.host,
|
|
507
|
+
port: config.server.port,
|
|
508
|
+
listening: false,
|
|
509
|
+
error: null,
|
|
510
|
+
}
|
|
511
|
+
this.#standalone = standaloneState
|
|
512
|
+
server.on('error', (error) => {
|
|
513
|
+
standaloneState.error = errorMessage(error)
|
|
514
|
+
standaloneState.listening = false
|
|
515
|
+
logger.error(
|
|
516
|
+
`[a2a] standalone listener ${config.server.host}:${config.server.port} failed: ${errorMessage(error)}`,
|
|
517
|
+
)
|
|
518
|
+
})
|
|
519
|
+
server.on('listening', () => {
|
|
520
|
+
standaloneState.listening = true
|
|
521
|
+
standaloneState.error = null
|
|
522
|
+
logger.info(
|
|
523
|
+
`[a2a] standalone listener ready on ${formatUrlHost(config.server.host)}:${config.server.port}`,
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
try {
|
|
527
|
+
server.listen(config.server.port, config.server.host)
|
|
528
|
+
} catch (error) {
|
|
529
|
+
standaloneState.error = errorMessage(error)
|
|
530
|
+
logger.error(`[a2a] standalone listener cannot bind: ${errorMessage(error)}`)
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
registerRoute({ kind: 'prefix', path: mountPath }, callA2A, 'A2A endpoint')
|
|
534
|
+
}
|
|
535
|
+
} catch (error) {
|
|
536
|
+
cleanupAfterFailure()
|
|
537
|
+
throw error
|
|
538
|
+
}
|
|
359
539
|
|
|
360
540
|
const sweepTimer = setInterval(() => {
|
|
361
541
|
try {
|
|
@@ -384,11 +564,21 @@ export class A2AService {
|
|
|
384
564
|
runtime.start()
|
|
385
565
|
|
|
386
566
|
const startedAt = Date.now()
|
|
387
|
-
this.#mounted = { startedAt }
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
567
|
+
this.#mounted = { startedAt, publicBaseUrl, standalone }
|
|
568
|
+
if (standalone) {
|
|
569
|
+
logger.info(
|
|
570
|
+
`A2A v${A2A_PROTOCOL_VERSION} standalone server: ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
|
|
571
|
+
`${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
|
|
572
|
+
)
|
|
573
|
+
logger.info(
|
|
574
|
+
`settings panel and card mirror on the Harness Web origin: ${webServer.getOrigin()}${mountPath}/panel/config`,
|
|
575
|
+
)
|
|
576
|
+
} else {
|
|
577
|
+
logger.info(
|
|
578
|
+
`A2A v${A2A_PROTOCOL_VERSION} server ready (shared port): ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
|
|
579
|
+
`${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
|
|
580
|
+
)
|
|
581
|
+
}
|
|
392
582
|
logger.info(
|
|
393
583
|
`auth=${authenticator.describe().mode} streaming=${config.server.streaming} ` +
|
|
394
584
|
`push=${config.server.pushNotifications} extendedCard=${extendedCard}`,
|
|
@@ -406,6 +596,15 @@ export class A2AService {
|
|
|
406
596
|
logger.warn(`[a2a] route disposal failed: ${errorMessage(error)}`)
|
|
407
597
|
}
|
|
408
598
|
}
|
|
599
|
+
const state = this.#standalone
|
|
600
|
+
this.#standalone = undefined
|
|
601
|
+
if (state !== undefined) {
|
|
602
|
+
await new Promise((resolve) => {
|
|
603
|
+
state.server.close(() => resolve())
|
|
604
|
+
state.server.closeAllConnections?.()
|
|
605
|
+
})
|
|
606
|
+
state.listening = false
|
|
607
|
+
}
|
|
409
608
|
await executor.dispose()
|
|
410
609
|
await runtime.shutdown()
|
|
411
610
|
this.#mounted = undefined
|
|
@@ -426,6 +625,7 @@ export class A2AService {
|
|
|
426
625
|
version: config.card.version,
|
|
427
626
|
publicUrl: publicBaseUrl,
|
|
428
627
|
uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
|
|
628
|
+
server: this.serverStatus(),
|
|
429
629
|
interfaces: [
|
|
430
630
|
{ protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
|
|
431
631
|
{ protocolBinding: 'HTTP+JSON', url: `${publicBaseUrl}${config.server.mountPath}/rest` },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai_mind/dsh-a2a",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Production A2A (Agent2Agent) v1.0 server for DeepSeek Harness agents: JSON-RPC + HTTP+JSON bindings, streaming, tasks, push notifications and signed Agent Cards.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|