@ai_mind/dsh-a2a 0.0.0-stage → 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.
@@ -0,0 +1,26 @@
1
+ # dsh-a2a bundle patch: mounts the A2A (Agent2Agent) v1.0 server row.
2
+ #
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:
7
+ #
8
+ # - id: a2a-server
9
+ # name: '@ai_mind/dsh-a2a'
10
+ # config:
11
+ # server:
12
+ # publicUrl: 'https://agent.example.com'
13
+ # auth:
14
+ # mode: bearer
15
+ # tokenEnv: A2A_AUTH_TOKEN
16
+ #
17
+ # A patch replaces the targeted row's whole `config`, so restate every field
18
+ # you keep from this file when overriding the row.
19
+ - insert:
20
+ - id: a2a-server
21
+ name: '@ai_mind/dsh-a2a'
22
+ config:
23
+ enabled: true
24
+ server:
25
+ host: 127.0.0.1
26
+ port: 41241
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="A2A server">
2
+ <defs>
3
+ <linearGradient id="a2a-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2f6df6"/>
5
+ <stop offset="1" stop-color="#7b3cf0"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="url(#a2a-bg)"/>
9
+ <circle cx="18" cy="32" r="7" fill="none" stroke="#ffffff" stroke-width="3.5"/>
10
+ <circle cx="46" cy="32" r="7" fill="none" stroke="#ffffff" stroke-width="3.5"/>
11
+ <path d="M25.5 32h13" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round"/>
12
+ <path d="M32 25.5l6.5 6.5-6.5 6.5" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
13
+ <circle cx="18" cy="32" r="2.2" fill="#ffffff"/>
14
+ <circle cx="46" cy="32" r="2.2" fill="#ffffff"/>
15
+ </svg>
package/index.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @ai_mind/dsh-a2a — an Agent2Agent (A2A) v1.0 server for DeepSeek Harness.
3
+ *
4
+ * The plugin exposes the local Harness agent over the official A2A protocol:
5
+ * JSON-RPC and HTTP+JSON bindings, blocking and streaming message sends, task
6
+ * listing/cancellation/resubscription, push notifications, and JWS-signed
7
+ * Agent Cards. It mounts on the Harness Web server, so the reachable origin is
8
+ * the same listener that serves the GUI.
9
+ *
10
+ * Configuration lives on the bundle row; see README.md for every field.
11
+ *
12
+ * @module @ai_mind/dsh-a2a
13
+ */
14
+
15
+ import { A2AService } from './lib/server.js'
16
+ import { normalizeConfig } from './lib/config.js'
17
+ import { ensureClientHalfDiscovered } from './lib/client-discovery.js'
18
+
19
+ /** Diagnostic plugin name shown in loader errors. */
20
+ export const name = 'a2a-server'
21
+
22
+ /**
23
+ * The server cannot work without the agent registry: every A2A task is
24
+ * executed by a real Harness agent.
25
+ */
26
+ export const inject = ['agents']
27
+
28
+ /**
29
+ * Mount the A2A server.
30
+ *
31
+ * @param {import('@deepseek-ai/cordis').Context} ctx
32
+ * @param {unknown} rawConfig the bundle row's `config`
33
+ */
34
+ export function apply(ctx, rawConfig) {
35
+ let config
36
+ try {
37
+ config = normalizeConfig(rawConfig)
38
+ } catch (error) {
39
+ throw new Error(`a2a: invalid configuration: ${error.message}`)
40
+ }
41
+ if (!config.enabled) {
42
+ ctx.logger?.info?.('[a2a] plugin is disabled by config')
43
+ return
44
+ }
45
+
46
+ const service = new A2AService(ctx, config)
47
+ ctx.effect(() => () => service.dispose(), 'a2a: service lifecycle')
48
+
49
+ ctx.inject(['webServer'], (webCtx) => {
50
+ webCtx.effect(() => service.mount(webCtx.webServer), 'a2a: http routes')
51
+ })
52
+
53
+ // A profile that installed an older build without a browser half keeps a
54
+ // negative discovery verdict for this specifier; reconcile it so the
55
+ // settings panel appears without a restart.
56
+ ctx.inject(['clientModules'], (clientCtx) => {
57
+ ensureClientHalfDiscovered(clientCtx, service.logger)
58
+ })
59
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Authentication for the A2A endpoint.
3
+ *
4
+ * Three modes are supported:
5
+ * - `none` : no credentials; only loopback callers are admitted unless
6
+ * `auth.allowRemoteUnauthenticated` is explicitly enabled.
7
+ * - `bearer` : `Authorization: Bearer <token>`
8
+ * - `apiKey` : a configurable header carrying the token (default `x-api-key`)
9
+ *
10
+ * The public Agent Card stays reachable without credentials (it must be, for
11
+ * discovery); every other route is protected.
12
+ *
13
+ * @module @ai_mind/dsh-a2a/auth
14
+ */
15
+
16
+ import { constantTimeEqual, isLoopbackAddress } from './util.js'
17
+
18
+ /** Credential extractors per auth mode. */
19
+ const EXTRACTORS = {
20
+ bearer(req) {
21
+ const header = req.headers.authorization
22
+ if (typeof header !== 'string') return null
23
+ const match = /^\s*Bearer\s+(.+?)\s*$/i.exec(header)
24
+ return match ? match[1] : null
25
+ },
26
+ apiKey(req, config) {
27
+ const raw = req.headers[config.auth.headerName]
28
+ if (typeof raw === 'string' && raw.trim() !== '') return raw.trim()
29
+ if (Array.isArray(raw) && typeof raw[0] === 'string') return raw[0].trim()
30
+ return null
31
+ },
32
+ }
33
+
34
+ /**
35
+ * Write a 401 in the shape of the binding the caller used.
36
+ *
37
+ * @param {import('node:http').IncomingMessage} req
38
+ * @param {import('node:http').ServerResponse} res
39
+ * @param {object} options
40
+ * @param {string} options.message
41
+ * @param {string} options.challenge
42
+ * @param {string} options.contentType
43
+ * @param {boolean} options.restBinding
44
+ */
45
+ function writeUnauthorized(req, res, { message, challenge, contentType, restBinding }) {
46
+ if (res.headersSent) return
47
+ res.statusCode = 401
48
+ res.setHeader('Content-Type', contentType)
49
+ res.setHeader('WWW-Authenticate', challenge)
50
+ res.setHeader('Cache-Control', 'no-store')
51
+ const details = [
52
+ {
53
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
54
+ reason: 'UNAUTHENTICATED',
55
+ domain: 'a2a-protocol.org',
56
+ metadata: { path: String(req.url ?? '') },
57
+ },
58
+ ]
59
+ if (restBinding) {
60
+ res.end(
61
+ JSON.stringify({
62
+ error: { code: 401, status: 'UNAUTHENTICATED', message, details },
63
+ }),
64
+ )
65
+ return
66
+ }
67
+ res.end(
68
+ JSON.stringify({
69
+ jsonrpc: '2.0',
70
+ id: null,
71
+ error: { code: -32000, message, data: details },
72
+ }),
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Create the authenticator for one mounted deployment.
78
+ *
79
+ * @param {object} options
80
+ * @param {Readonly<object>} options.config normalized configuration
81
+ * @param {{ id: string, token: string }[]} options.credentials resolved secrets
82
+ * @param {{ warn: (...args: unknown[]) => void, info: (...args: unknown[]) => void }} options.logger
83
+ * @param {(req: import('node:http').IncomingMessage) => boolean} options.isRestBinding
84
+ * @returns {{
85
+ * enabled: boolean,
86
+ * middleware: import('express').RequestHandler,
87
+ * userBuilder: (req: import('node:http').IncomingMessage) => object,
88
+ * describe: () => object,
89
+ * }}
90
+ */
91
+ export function createAuthenticator({ config, credentials, logger, isRestBinding }) {
92
+ const mode = config.auth.mode
93
+ const enabled = mode !== 'none'
94
+ if (enabled && credentials.length === 0) {
95
+ throw new Error(
96
+ `a2a: auth.mode is "${mode}" but no credentials are configured; ` +
97
+ 'set auth.tokens, auth.token, auth.clients or auth.tokenEnv',
98
+ )
99
+ }
100
+
101
+ const challenge =
102
+ mode === 'bearer'
103
+ ? `Bearer realm="${config.auth.realm}"`
104
+ : `ApiKey realm="${config.auth.realm}", header="${config.auth.headerName}"`
105
+
106
+ const contentType = 'application/a2a+json'
107
+ let warnedRemote = false
108
+
109
+ /** @returns {{ id: string } | null} */
110
+ function identify(req) {
111
+ const extractor = EXTRACTORS[mode]
112
+ if (extractor === undefined) return null
113
+ const presented = extractor(req, config)
114
+ if (presented === null) return null
115
+ let matched = null
116
+ for (const credential of credentials) {
117
+ // Every candidate is compared so timing does not reveal which one matched.
118
+ if (constantTimeEqual(presented, credential.token) && matched === null) matched = credential
119
+ }
120
+ return matched === null ? null : { id: matched.id }
121
+ }
122
+
123
+ function middleware(req, res, next) {
124
+ const peer = req.ip || req.socket?.remoteAddress || ''
125
+ const restBinding = isRestBinding(req)
126
+
127
+ if (!enabled) {
128
+ if (!config.auth.allowRemoteUnauthenticated && !isLoopbackAddress(peer)) {
129
+ if (!warnedRemote) {
130
+ warnedRemote = true
131
+ logger.warn(
132
+ `[a2a] rejected unauthenticated request from ${peer || 'unknown peer'}; ` +
133
+ 'configure auth or set auth.allowRemoteUnauthenticated=true to allow remote callers',
134
+ )
135
+ }
136
+ writeUnauthorized(req, res, {
137
+ message: 'Unauthenticated: this A2A endpoint only accepts loopback callers without configured credentials.',
138
+ challenge,
139
+ contentType,
140
+ restBinding,
141
+ })
142
+ return
143
+ }
144
+ next()
145
+ return
146
+ }
147
+
148
+ const identity = identify(req)
149
+ if (identity === null) {
150
+ writeUnauthorized(req, res, {
151
+ message: 'Unauthenticated: missing or invalid A2A credentials.',
152
+ challenge,
153
+ contentType,
154
+ restBinding,
155
+ })
156
+ return
157
+ }
158
+ req.a2aClient = identity
159
+ next()
160
+ }
161
+
162
+ function userBuilder(req) {
163
+ const identity = req.a2aClient
164
+ if (!identity) {
165
+ return {
166
+ get isAuthenticated() {
167
+ return false
168
+ },
169
+ get userName() {
170
+ return ''
171
+ },
172
+ }
173
+ }
174
+ return {
175
+ get isAuthenticated() {
176
+ return true
177
+ },
178
+ get userName() {
179
+ return identity.id
180
+ },
181
+ }
182
+ }
183
+
184
+ return {
185
+ enabled,
186
+ middleware,
187
+ userBuilder,
188
+ describe: () => ({
189
+ mode,
190
+ enabled,
191
+ clientCount: credentials.length,
192
+ header: mode === 'apiKey' ? config.auth.headerName : 'authorization',
193
+ allowRemoteUnauthenticated: config.auth.allowRemoteUnauthenticated,
194
+ }),
195
+ }
196
+ }