@ai_mind/dsh-a2a 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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),本插件负责把它挂到 Harness 的 Web 服务器上,并把 A2A 任务桥接到真实的 Harness Agent:每个 A2A `contextId` 对应一个可复用的 Harness 会话,任务取消、超时、并发上限、沙箱与审批策略都由插件统一管理。
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:57133/.well-known/agent-card.json | jq .
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:57133/a2a \
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:57133/a2a \
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` 使用 **Harness Web 连接凭据**(不是 A2A token)鉴权:只有能打开 Settings 的已登录浏览器可以读写,远程 A2A 客户端即使知道路径也会收到 401。令牌字段是只写字段,GET 只返回“是否已配置”。
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:57133/.well-known/agent-card.json | jq .
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:57133/a2a \
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:57133/a2a \
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:57133')
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:57133/a2a/rest/message:send \
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 .
@@ -285,6 +285,27 @@ agent:
285
285
 
286
286
  ---
287
287
 
288
+ ## 独立端口(不与 DSH 复用)
289
+
290
+ 插件配置的默认值是 `server.port: null`:A2A 直接复用 DSH Web 的端口,Agent Card、JSON-RPC、REST 和设置面板共享同一个 origin。本仓库的 bundle patch 为了方便独立部署,默认设为 `127.0.0.1:41241`。需要显式控制时:
291
+
292
+ ```yaml
293
+ server:
294
+ host: 127.0.0.1
295
+ port: 41241
296
+ ```
297
+
298
+ 此时:
299
+
300
+ - A2A 在 `http://127.0.0.1:41241/a2a` 单独监听(REST 为 `/a2a/rest`),Agent Card 和 `health` 也在该端口;
301
+ - DSH Web 端口只保留卡片镜像 `/.well-known/agent-card.json`(供设置页发现)和 `<mountPath>/panel` 配置面板(仍使用 GUI 登录凭据,不暴露在 A2A 端口上);
302
+ - Agent Card 的 `supportedInterfaces` 使用 `http://host:port`;如果前面有反向代理/TLS 终止,设置 `server.publicUrl` 为外部地址;
303
+ - 独立监听本身是 HTTP;公网部署时由反向代理终止 TLS。
304
+
305
+ 本仓库的 `cordis.patch.yml` 默认使用 `127.0.0.1:41241`;修改 `server.port`(或在设置页的“监听端口”保存)后插件会重载并切换监听。若端口被占用,设置页会显示监听错误。
306
+
307
+ ---
308
+
288
309
  ## 配置参考
289
310
 
290
311
  完整默认值见 [`lib/config.js`](lib/config.js)。所有字段均可在 bundle 行 `config` 中覆盖;未知字段会令插件激活失败(避免拼写错误静默生效)。
@@ -295,7 +316,9 @@ enabled: true
295
316
  server:
296
317
  mountPath: /a2a
297
318
  cardPath: /.well-known/agent-card.json
298
- publicUrl: null # 对外可达的 origin;不填则由 WebServer 的绑定地址推导
319
+ publicUrl: null # 对外可达的 origin;独立端口时默认 http://host:port
320
+ host: 127.0.0.1 # 独立监听的绑定地址(仅 server.port 非 null 时生效)
321
+ port: null # null = 复用 DSH Web 端口;1-65535 = 独立监听
299
322
  trustProxy: false # 反向代理后设为 true,以使用 X-Forwarded-For
300
323
  streaming: true
301
324
  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: '活跃上下文',
@@ -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',
@@ -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 base = `${window.location.origin}${url.pathname.replace(/\/$/, '')}`
431
- try {
432
- const healthResponse = await fetch(`${base}/health`, { cache: 'no-store', signal: controller.signal })
433
- if (healthResponse.ok) health = await healthResponse.json()
434
- else denied = healthResponse.status === 401 || healthResponse.status === 403
435
- } catch {
436
- denied = true
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
  }
@@ -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. To expose the agent to
4
- # other hosts, set `server.publicUrl` and enable `auth` in the profile patch:
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
- export function createPanelRouter({ ctx, config, mountPath, logger }) {
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({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...payload })
287
+ res.json({
288
+ ok: true,
289
+ plugin: { name: PLUGIN_NAME, version: '1.1.0' },
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) => {
@@ -350,7 +378,13 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
350
378
  /* keep the submitted view when the row is mid-reload */
351
379
  }
352
380
  logger.info('[a2a] configuration updated from the settings panel')
353
- res.json({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...describe(live, liveEntry, []) })
381
+ res.json({
382
+ ok: true,
383
+ plugin: { name: PLUGIN_NAME, version: '1.1.0' },
384
+ mountPath,
385
+ ...describe(live, liveEntry, []),
386
+ ...(typeof status === 'function' ? { server: status() } : {}),
387
+ })
354
388
  })
355
389
 
356
390
  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. This module mounts that engine on the
7
- * Harness Web server and wires it to the local agent runtime.
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
- /** Derive the externally visible origin, preferring the configured URL. */
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
- * Register routes on the Harness Web server.
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 and drains work
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, config.server.mountPath),
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
- // The Agent Card is discovery metadata and stays public.
269
- app.use(config.server.cardPath, cardRouter)
270
- // The settings panel is authenticated by the Harness Web connection, not
271
- // by the A2A token, and must be mounted before the A2A auth middleware.
272
- app.use(`${mountPath}/panel`, createPanelRouter({ ctx: this.#ctx, config, mountPath, logger }))
273
- // Everything else below the mount path is the A2A endpoint.
274
- app.use(mountPath, authenticator.middleware)
275
- app.use(`${mountPath}/rest`, restRouter)
276
- app.use(mountPath, jsonRpcRouter)
277
-
278
- app.get(mountPath, (req, res) => {
279
- res.setHeader('Allow', 'POST')
280
- writeError(req, res, {
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
- if (config.server.health) {
289
- app.get(`${mountPath}/health`, (req, res) => {
290
- res.setHeader('Cache-Control', 'no-store')
291
- res.json(this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }))
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
- app.use((req, res) => {
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 callApp = (req, res) => {
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
- const registerRoute = (route, label, { optional = false } = {}) => {
462
+ let standaloneState
463
+ const registerRoute = (route, handler, label, { optional = false } = {}) => {
346
464
  try {
347
- routeDisposers.push(webServer.register({ ...route, handler: callApp }))
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
- registerRoute({ kind: 'exact', path: config.server.cardPath }, 'agent card')
358
- registerRoute({ kind: 'prefix', path: mountPath }, 'A2A endpoint')
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
- logger.info(
389
- `A2A v${A2A_PROTOCOL_VERSION} server ready: ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
390
- `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
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.0.0",
3
+ "version": "1.1.0",
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",