@ai_mind/dsh-a2a 1.1.1 → 1.1.3

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
@@ -247,7 +247,7 @@ auth:
247
247
  - 请求体默认上限 8 MiB;单个 `raw` 附件默认上限 16 MiB。
248
248
  - 公开卡与受保护端点分离:卡片可公开发现,其余端点一律鉴权。
249
249
 
250
- > **重要**:暴露到公网前请务必配置 `auth`、`server.publicUrl`,并将 `agent.sandboxMode`/`agent.approvalPolicy` 调整到符合你信任模型的取值。A2A 调用方能够让 Agent 在其工作区内执行任务,这与给陌生人一个 Harness 会话等价。
250
+ > **重要**:暴露到公网前请务必配置 `auth`、正确的 `Host`/`X-Forwarded-*`(或 `server.publicUrl`),并将 `agent.sandboxMode`/`agent.approvalPolicy` 调整到符合你信任模型的取值。A2A 调用方能够让 Agent 在其工作区内执行任务,这与给陌生人一个 Harness 会话等价。
251
251
  >
252
252
  > 强烈建议把 `agent.cwd` 指向一个**专用工作区**(而不是默认的家目录):`workspace-write` 的边界就是该目录。公网部署还应在反向代理层配置限流、请求体上限与 TLS。
253
253
 
@@ -309,11 +309,32 @@ server:
309
309
 
310
310
  - A2A 在 `http://127.0.0.1:41241/a2a` 单独监听(REST 为 `/a2a/rest`),Agent Card 和 `health` 也在该端口;
311
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。
312
+ - Agent Card 和 `health` 的 URL 默认根据**请求的 `Host`** 推导;设置 `server.trustProxy: true` 后同时使用 `X-Forwarded-Host` / `X-Forwarded-Proto`。`server.publicUrl` 始终优先;
313
+ - 独立监听本身是 HTTP;公网部署时由反向代理终止 TLS,并转发 `Host` 与 `X-Forwarded-*`。
314
314
 
315
315
  本仓库的 `cordis.patch.yml` 默认使用 `127.0.0.1:41241`;修改 `server.port`(或在设置页的“监听端口”保存)后插件会重载并切换监听。若端口被占用,设置页会显示监听错误。
316
316
 
317
+ ### 反向代理
318
+
319
+ A2A 前面的 Nginx/Caddy 等反代需要保留原始 `Host` 和协议头:
320
+
321
+ ```nginx
322
+ proxy_set_header Host $host;
323
+ proxy_set_header X-Forwarded-Proto $scheme;
324
+ proxy_set_header X-Forwarded-Host $host;
325
+ ```
326
+
327
+ 并设置:
328
+
329
+ ```yaml
330
+ server:
331
+ trustProxy: true
332
+ ```
333
+
334
+ 这样 Agent Card 与 `health` 会返回外部 origin(例如 `https://a2a.example.com/a2a`),不需要写死 `publicUrl`。如果代理无法传递这些头,再显式设置 `server.publicUrl`。
335
+
336
+ > 设置页使用的**卡片镜像**仍位于 DSH Web 的 origin:它使用 `server.publicUrl`,否则使用 A2A 监听地址(独立端口下是 `http://host:port`)。如果 GUI 与 A2A 通过不同外部域名暴露,请设置 `publicUrl`。
337
+
317
338
  ---
318
339
 
319
340
  ## 配置参考
@@ -326,10 +347,10 @@ enabled: true
326
347
  server:
327
348
  mountPath: /a2a
328
349
  cardPath: /.well-known/agent-card.json
329
- publicUrl: null # 对外可达的 origin;独立端口时默认 http://host:port
350
+ publicUrl: null # 显式对外 origin;留空则按请求 Host / X-Forwarded-* 推导
330
351
  host: 127.0.0.1 # 独立监听的绑定地址(仅 server.port 非 null 时生效)
331
352
  port: null # null = 复用 DSH Web 端口;1-65535 = 独立监听
332
- trustProxy: false # 反向代理后设为 true,以使用 X-Forwarded-For
353
+ trustProxy: false # 反向代理后设为 true,以使用 X-Forwarded-For 并推导对外地址
333
354
  streaming: true
334
355
  pushNotifications: true
335
356
  extendedCard: false # 启用鉴权后才生效
package/client.js CHANGED
@@ -96,6 +96,7 @@ window.__ModuleLoader__.load({
96
96
  fieldServerHost: '监听地址',
97
97
  fieldServerPort: '监听端口',
98
98
  fieldServerMountPath: '挂载路径',
99
+ fieldServerTrustProxy: '信任代理头(X-Forwarded-*)',
99
100
  fieldServerStreaming: '流式 SSE',
100
101
  fieldServerPushNotifications: '推送通知',
101
102
  fieldServerExtendedCard: '扩展 Agent Card',
@@ -207,6 +208,7 @@ window.__ModuleLoader__.load({
207
208
  fieldServerHost: 'Listen host',
208
209
  fieldServerPort: 'Listen port',
209
210
  fieldServerMountPath: 'Mount path',
211
+ fieldServerTrustProxy: 'Trust proxy headers (X-Forwarded-*)',
210
212
  fieldServerStreaming: 'Streaming SSE',
211
213
  fieldServerPushNotifications: 'Push notifications',
212
214
  fieldServerExtendedCard: 'Extended Agent Card',
package/lib/panel.js CHANGED
@@ -41,6 +41,7 @@ const FIELDS = [
41
41
  placeholder: '留空 = 复用 DSH 端口',
42
42
  },
43
43
  { path: 'server.mountPath', type: 'string', group: 'server' },
44
+ { path: 'server.trustProxy', type: 'boolean', group: 'server' },
44
45
  { path: 'server.streaming', type: 'boolean', group: 'server' },
45
46
  { path: 'server.pushNotifications', type: 'boolean', group: 'server' },
46
47
  { path: 'server.extendedCard', type: 'boolean', group: 'server' },
@@ -286,7 +287,7 @@ export function createPanelRouter({ ctx, config, mountPath, logger, status, heal
286
287
  res.setHeader('Cache-Control', 'no-store')
287
288
  res.json({
288
289
  ok: true,
289
- plugin: { name: PLUGIN_NAME, version: '1.1.1' },
290
+ plugin: { name: PLUGIN_NAME, version: '1.1.3' },
290
291
  mountPath,
291
292
  ...payload,
292
293
  ...(typeof status === 'function' ? { server: status() } : {}),
@@ -383,7 +384,7 @@ export function createPanelRouter({ ctx, config, mountPath, logger, status, heal
383
384
  logger.info('[a2a] configuration updated from the settings panel')
384
385
  res.json({
385
386
  ok: true,
386
- plugin: { name: PLUGIN_NAME, version: '1.1.1' },
387
+ plugin: { name: PLUGIN_NAME, version: '1.1.3' },
387
388
  mountPath,
388
389
  ...describe(live, liveEntry, []),
389
390
  ...(typeof status === 'function' ? { server: status() } : {}),
package/lib/server.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import http from 'node:http'
19
+ import { createHash } from 'node:crypto'
19
20
  import { isIP } from 'node:net'
20
21
  import express from 'express'
21
22
  import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
@@ -23,8 +24,9 @@ import {
23
24
  DefaultPushNotificationSender,
24
25
  DefaultRequestHandler,
25
26
  InMemoryPushNotificationStore,
27
+ STATE_HEADERS_KEY,
26
28
  } from '@a2a-js/sdk/server'
27
- import { agentCardHandler, jsonRpcHandler, restHandler } from '@a2a-js/sdk/server/express'
29
+ import { jsonRpcHandler, restHandler } from '@a2a-js/sdk/server/express'
28
30
  import { buildAgentCard, buildExtendedCard, createCardSigner, isAuthEnabled } from './card.js'
29
31
  import { createAuthenticator } from './auth.js'
30
32
  import { createPanelRouter } from './panel.js'
@@ -62,6 +64,42 @@ function formatUrlHost(host) {
62
64
  return isIP(host) === 6 ? `[${host}]` : host
63
65
  }
64
66
 
67
+ /** First value of a possibly repeated HTTP header. */
68
+ function firstHeader(value) {
69
+ if (Array.isArray(value)) return value[0]
70
+ return typeof value === 'string' ? value : undefined
71
+ }
72
+
73
+ /**
74
+ * Derive the externally visible origin from the request headers.
75
+ *
76
+ * `server.publicUrl` always wins. When `server.trustProxy` is enabled, the
77
+ * first `X-Forwarded-Host` / `X-Forwarded-Proto` values are honored so a
78
+ * reverse proxy can preserve its external origin. Otherwise the raw `Host`
79
+ * header is used. Returns null when no usable host is present.
80
+ *
81
+ * @param {Record<string, string|string[]|undefined>} headers
82
+ * @param {Readonly<object>} config
83
+ * @param {string} fallbackProtocol protocol to use without a forwarded proto
84
+ * @returns {string|null}
85
+ */
86
+ function baseFromHeaders(headers, config, fallbackProtocol = 'http') {
87
+ if (config.server.publicUrl) return trimTrailingSlash(config.server.publicUrl)
88
+ const trusted = config.server.trustProxy === true
89
+ const forwardedHost = firstHeader(headers?.['x-forwarded-host'])
90
+ const host = trusted && forwardedHost ? forwardedHost.split(',')[0].trim() : firstHeader(headers?.host)
91
+ if (!host) return null
92
+ const forwardedProto = firstHeader(headers?.['x-forwarded-proto'])
93
+ const protocol = trusted && forwardedProto ? forwardedProto.split(',')[0].trim() : fallbackProtocol
94
+ try {
95
+ const url = new URL(`${protocol}://${host}`)
96
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
97
+ return trimTrailingSlash(url.origin)
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
65
103
  /**
66
104
  * Derive the externally visible origin.
67
105
  *
@@ -75,8 +113,8 @@ function resolvePublicBase(config, webServer, logger) {
75
113
  const host = config.server.host
76
114
  if (host === '0.0.0.0' || host === '::') {
77
115
  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.',
116
+ `[a2a] server.host ${host} is a wildcard; the static fallback advertises a loopback URL. ` +
117
+ 'Agent Card and health use the request Host; set server.publicUrl only if the proxy cannot forward Host/forwarded headers.',
80
118
  )
81
119
  }
82
120
  const advertised = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host
@@ -274,6 +312,17 @@ export class A2AService {
274
312
  ? undefined
275
313
  : new DefaultPushNotificationSender(pushStore, { timeout: config.push.timeoutMs })
276
314
 
315
+ const extendedAgentCardProvider = extendedCard
316
+ ? async (context) => {
317
+ const headers =
318
+ context?.state?.get?.(STATE_HEADERS_KEY) ?? context?.state?.get?.('headers') ?? {}
319
+ const base = baseFromHeaders(headers, config, 'http')
320
+ return base === null
321
+ ? extendedAgentCard
322
+ : buildExtendedCard(effective, { publicBaseUrl: base, credentials })
323
+ }
324
+ : undefined
325
+
277
326
  const requestHandler = new DefaultRequestHandler(
278
327
  baseCard,
279
328
  taskStore,
@@ -281,11 +330,54 @@ export class A2AService {
281
330
  undefined,
282
331
  pushStore,
283
332
  pushSender,
284
- extendedCard ? async () => extendedAgentCard : undefined,
333
+ extendedAgentCardProvider,
285
334
  signer ?? undefined,
286
335
  { validateInputModes: true },
287
336
  )
288
337
 
338
+ const serveCard = async (req, res, base) => {
339
+ try {
340
+ const card = buildAgentCard(effective, { publicBaseUrl: base, credentials })
341
+ const served = signer === null ? card : await signer(card)
342
+ const body = JSON.stringify(served)
343
+ res.setHeader('ETag', `W/"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`)
344
+ res.setHeader('Vary', 'Host, X-Forwarded-Host, X-Forwarded-Proto')
345
+ res.setHeader(
346
+ 'Cache-Control',
347
+ config.server.publicUrl && config.server.cardCacheSeconds > 0
348
+ ? `public, max-age=${config.server.cardCacheSeconds}`
349
+ : 'no-cache',
350
+ )
351
+ if (req.fresh) {
352
+ res.statusCode = 304
353
+ res.end()
354
+ return
355
+ }
356
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
357
+ res.statusCode = 200
358
+ res.end(body)
359
+ } catch (error) {
360
+ logger.warn(`[a2a] agent card generation failed: ${errorMessage(error)}`)
361
+ if (!res.headersSent) {
362
+ res.statusCode = 500
363
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
364
+ res.end(JSON.stringify({ error: 'INTERNAL', message: 'cannot generate the Agent Card' }))
365
+ } else if (!res.writableEnded) {
366
+ res.end()
367
+ }
368
+ }
369
+ }
370
+ const dynamicCardHandler = (req, res) =>
371
+ serveCard(req, res, baseFromHeaders(req.headers, config, req.protocol) ?? publicBaseUrl)
372
+ // The DSH origin only mirrors the card for panel discovery; its interfaces
373
+ // must point at the A2A listener (or publicUrl), not at the GUI origin.
374
+ const mirrorCardHandler = (req, res) =>
375
+ serveCard(
376
+ req,
377
+ res,
378
+ config.server.publicUrl ? trimTrailingSlash(config.server.publicUrl) : publicBaseUrl,
379
+ )
380
+
289
381
  const authenticator = createAuthenticator({
290
382
  config,
291
383
  credentials,
@@ -293,14 +385,10 @@ export class A2AService {
293
385
  isRestBinding: (req) => isRestBinding(req, mountPath),
294
386
  })
295
387
 
296
- const cardRouter = agentCardHandler({
297
- agentCardProvider: requestHandler,
298
- cache: { maxAge: config.server.cardCacheSeconds },
299
- })
300
388
  const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
301
389
  const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
302
- const healthSnapshot = () =>
303
- this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard })
390
+ const healthSnapshot = (base = publicBaseUrl) =>
391
+ this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl: base, extendedCard })
304
392
  const panelRouter = createPanelRouter({
305
393
  ctx: this.#ctx,
306
394
  config,
@@ -389,8 +477,9 @@ export class A2AService {
389
477
 
390
478
  const a2aApp = express()
391
479
  applyCommon(a2aApp)
392
- // The Agent Card is discovery metadata and stays public.
393
- a2aApp.use(config.server.cardPath, cardRouter)
480
+ // The Agent Card is discovery metadata and stays public. Its interface URLs
481
+ // are derived per request so a reverse proxy's external Host is preserved.
482
+ a2aApp.get(config.server.cardPath, dynamicCardHandler)
394
483
  if (!standalone) {
395
484
  // In shared mode the panel is part of the same app and must precede the
396
485
  // A2A auth middleware.
@@ -411,8 +500,9 @@ export class A2AService {
411
500
  })
412
501
  if (config.server.health) {
413
502
  a2aApp.get(`${mountPath}/health`, (req, res) => {
503
+ const base = baseFromHeaders(req.headers, config, req.protocol) ?? publicBaseUrl
414
504
  res.setHeader('Cache-Control', 'no-store')
415
- res.json(healthSnapshot())
505
+ res.json(healthSnapshot(base))
416
506
  })
417
507
  }
418
508
  a2aApp.use((req, res) => {
@@ -493,7 +583,11 @@ export class A2AService {
493
583
  // Discovery stays reachable on the Harness Web origin in both modes: in
494
584
  // shared mode it is the endpoint, and in standalone mode it bootstraps
495
585
  // the settings panel (the card itself points at the dedicated port).
496
- registerRoute({ kind: 'exact', path: config.server.cardPath }, callA2A, 'agent card')
586
+ registerRoute(
587
+ { kind: 'exact', path: config.server.cardPath },
588
+ standalone ? mirrorCardHandler : callA2A,
589
+ standalone ? 'agent card mirror' : 'agent card',
590
+ )
497
591
  if (standalone) {
498
592
  registerRoute(
499
593
  { kind: 'prefix', path: `${mountPath}/panel` },
@@ -618,6 +712,8 @@ export class A2AService {
618
712
  #health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }) {
619
713
  const config = this.#config
620
714
  const mounted = this.#mounted
715
+ const server = this.serverStatus()
716
+ server.publicUrl = publicBaseUrl
621
717
  return {
622
718
  status: 'ok',
623
719
  protocolVersion: A2A_PROTOCOL_VERSION,
@@ -625,7 +721,7 @@ export class A2AService {
625
721
  version: config.card.version,
626
722
  publicUrl: publicBaseUrl,
627
723
  uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
628
- server: this.serverStatus(),
724
+ server,
629
725
  interfaces: [
630
726
  { protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
631
727
  { 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.1.1",
3
+ "version": "1.1.3",
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",