@ai_mind/dsh-a2a 0.0.0-stage → 1.0.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/lib/server.js ADDED
@@ -0,0 +1,456 @@
1
+ /**
2
+ * A2A server assembly: Agent Card, transports, authentication and DSH routes.
3
+ *
4
+ * The official `@a2a-js/sdk` supplies the protocol engine — task lifecycle,
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.
8
+ *
9
+ * @module @ai_mind/dsh-a2a/server
10
+ */
11
+
12
+ import express from 'express'
13
+ import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
14
+ import {
15
+ DefaultPushNotificationSender,
16
+ DefaultRequestHandler,
17
+ InMemoryPushNotificationStore,
18
+ } from '@a2a-js/sdk/server'
19
+ import { agentCardHandler, jsonRpcHandler, restHandler } from '@a2a-js/sdk/server/express'
20
+ import { buildAgentCard, buildExtendedCard, createCardSigner, isAuthEnabled } from './card.js'
21
+ import { createAuthenticator } from './auth.js'
22
+ import { createPanelRouter } from './panel.js'
23
+ import { A2ATaskStore, GuardedPushNotificationStore } from './store.js'
24
+ import { HarnessAgentExecutor } from './executor.js'
25
+ import { AgentRuntime } from './bridge.js'
26
+ import { errorMessage, trimTrailingSlash } from './util.js'
27
+ import { sweepAttachmentDirectories } from './files.js'
28
+ import { resolveCredentials } from './config.js'
29
+
30
+ const JSON_CONTENT_TYPE = 'application/json'
31
+ const A2A_CONTENT_TYPE = 'application/a2a+json'
32
+
33
+ /** Minimal structured logger over the Cordis logger. */
34
+ function createLogger(ctx) {
35
+ const render = (args) => ['[a2a]', ...args.map((value) => (typeof value === 'string' ? value : errorMessage(value)))].join(' ')
36
+ const emit = (level, args) => {
37
+ try {
38
+ const logger = ctx.logger
39
+ if (typeof logger?.[level] === 'function') logger[level](render(args))
40
+ else if (typeof logger?.log === 'function') logger.log(render(args))
41
+ } catch {
42
+ /* logging must never fail a request */
43
+ }
44
+ }
45
+ return {
46
+ info: (...args) => emit('info', args),
47
+ warn: (...args) => emit('warn', args),
48
+ error: (...args) => emit('error', args),
49
+ }
50
+ }
51
+
52
+ /** Derive the externally visible origin, preferring the configured URL. */
53
+ function resolvePublicBase(config, webServer, logger) {
54
+ if (config.server.publicUrl) return trimTrailingSlash(config.server.publicUrl)
55
+ try {
56
+ const origin = webServer.getOrigin()
57
+ if (typeof origin === 'string' && origin !== '') return trimTrailingSlash(origin)
58
+ } catch {
59
+ /* not listening yet; fall through */
60
+ }
61
+ const port = Number(webServer.port)
62
+ if (Number.isInteger(port) && port > 0) {
63
+ logger.warn(`[a2a] webServer.getOrigin() unavailable; advertising http://127.0.0.1:${port}`)
64
+ return `http://127.0.0.1:${port}`
65
+ }
66
+ logger.warn('[a2a] cannot determine a public origin; set server.publicUrl in the plugin config')
67
+ return 'http://127.0.0.1'
68
+ }
69
+
70
+ /** @returns {boolean} whether the request targets the REST binding. */
71
+ function isRestBinding(req, mountPath) {
72
+ const path = String(req.originalUrl ?? req.url ?? '')
73
+ return path.startsWith(`${mountPath}/rest`)
74
+ }
75
+
76
+ /** Build the CORS middleware for the configured policy. */
77
+ function createCors(config) {
78
+ const cors = config.server.cors
79
+ return (req, res, next) => {
80
+ if (!cors.enabled) {
81
+ next()
82
+ return
83
+ }
84
+ const origin = req.headers.origin
85
+ const wildcard = cors.origins.includes('*')
86
+ const allowed = wildcard ? '*' : origin !== undefined && cors.origins.includes(origin) ? origin : null
87
+ if (allowed !== null) {
88
+ res.setHeader('Access-Control-Allow-Origin', allowed)
89
+ if (!wildcard) res.setHeader('Vary', 'Origin')
90
+ if (cors.allowCredentials) res.setHeader('Access-Control-Allow-Credentials', 'true')
91
+ res.setHeader('Access-Control-Expose-Headers', 'A2A-Extensions, A2A-Version, ETag')
92
+ }
93
+ if (req.method === 'OPTIONS') {
94
+ if (allowed !== null) {
95
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
96
+ res.setHeader(
97
+ 'Access-Control-Allow-Headers',
98
+ `authorization, content-type, a2a-version, a2a-extensions, ${config.auth.headerName}`,
99
+ )
100
+ res.setHeader('Access-Control-Max-Age', String(cors.maxAgeSeconds))
101
+ }
102
+ res.statusCode = 204
103
+ res.end()
104
+ return
105
+ }
106
+ next()
107
+ }
108
+ }
109
+
110
+ /** Write one error body in the shape of the binding the caller used. */
111
+ function writeError(req, res, { status, code, message, restBinding, rpcCode = -32603 }) {
112
+ if (res.headersSent) {
113
+ res.end()
114
+ return
115
+ }
116
+ res.statusCode = status
117
+ res.setHeader('Content-Type', restBinding ? A2A_CONTENT_TYPE : JSON_CONTENT_TYPE)
118
+ res.setHeader('Cache-Control', 'no-store')
119
+ const details = [
120
+ {
121
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
122
+ reason: code,
123
+ domain: 'a2a-protocol.org',
124
+ metadata: { path: String(req.originalUrl ?? req.url ?? '') },
125
+ },
126
+ ]
127
+ if (restBinding) {
128
+ res.end(JSON.stringify({ error: { code: status, status: code, message, details } }))
129
+ } else {
130
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: rpcCode, message, data: details } }))
131
+ }
132
+ }
133
+
134
+ /**
135
+ * One mounted A2A deployment. Mounting is synchronous so route registration
136
+ * can participate in Cordis effects; only request handling touches async
137
+ * agent work.
138
+ */
139
+ export class A2AService {
140
+ #ctx
141
+ #config
142
+ #logger
143
+ #mounted = undefined
144
+ #unmount = undefined
145
+ #disposed = false
146
+
147
+ /**
148
+ * @param {object} ctx owning Cordis context
149
+ * @param {Readonly<object>} config normalized configuration
150
+ */
151
+ constructor(ctx, config) {
152
+ this.#ctx = ctx
153
+ this.#config = config
154
+ this.#logger = createLogger(ctx)
155
+ }
156
+
157
+ /** @returns {object} the structured logger used by every component */
158
+ get logger() {
159
+ return this.#logger
160
+ }
161
+
162
+ /**
163
+ * Register routes on the Harness Web server.
164
+ *
165
+ * @param {object} webServer the `webServer` service
166
+ * @returns {() => Promise<void>} disposer that unregisters routes and drains work
167
+ */
168
+ mount(webServer) {
169
+ if (this.#mounted !== undefined) throw new Error('a2a: already mounted')
170
+ if (this.#disposed) throw new Error('a2a: service is disposed')
171
+ const config = this.#config
172
+ const logger = this.#logger
173
+
174
+ const credentials = resolveCredentials(config)
175
+ const publicBaseUrl = resolvePublicBase(config, webServer, logger)
176
+ const authEnabled = isAuthEnabled(config, credentials)
177
+ let extendedCard = config.server.extendedCard
178
+ if (extendedCard && !authEnabled) {
179
+ logger.warn(
180
+ '[a2a] server.extendedCard requires authentication; the extended Agent Card stays disabled because auth.mode is "none"',
181
+ )
182
+ extendedCard = false
183
+ }
184
+
185
+ const effective = {
186
+ ...config,
187
+ server: { ...config.server, extendedCard, pushNotifications: config.server.pushNotifications },
188
+ }
189
+
190
+ const cardOptions = { publicBaseUrl, credentials }
191
+ const baseCard = buildAgentCard(effective, cardOptions)
192
+ const extendedAgentCard = buildExtendedCard(effective, cardOptions)
193
+ const signer = createCardSigner(config)
194
+
195
+ const runtime = new AgentRuntime({ ctx: this.#ctx, config, logger })
196
+ const executor = new HarnessAgentExecutor({ runtime, config, logger })
197
+ const taskStore = new A2ATaskStore({ ...config.tasks, logger })
198
+
199
+ const pushStore = config.server.pushNotifications
200
+ ? new GuardedPushNotificationStore(new InMemoryPushNotificationStore(), {
201
+ allowPrivateTargets: config.push.allowPrivateTargets,
202
+ maxConfigsPerTask: 10,
203
+ logger,
204
+ })
205
+ : undefined
206
+ const pushSender =
207
+ pushStore === undefined
208
+ ? undefined
209
+ : new DefaultPushNotificationSender(pushStore, { timeout: config.push.timeoutMs })
210
+
211
+ const requestHandler = new DefaultRequestHandler(
212
+ baseCard,
213
+ taskStore,
214
+ executor,
215
+ undefined,
216
+ pushStore,
217
+ pushSender,
218
+ extendedCard ? async () => extendedAgentCard : undefined,
219
+ signer ?? undefined,
220
+ { validateInputModes: true },
221
+ )
222
+
223
+ const authenticator = createAuthenticator({
224
+ config,
225
+ credentials,
226
+ 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()
239
+ })
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
+
260
+ const mountPath = config.server.mountPath
261
+ const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
262
+ const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
263
+ const cardRouter = agentCardHandler({
264
+ agentCardProvider: requestHandler,
265
+ cache: { maxAge: config.server.cardCacheSeconds },
266
+ })
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
+ })
286
+ })
287
+
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 }))
292
+ })
293
+ }
294
+
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) => {
305
+ if (res.headersSent) {
306
+ res.end()
307
+ return
308
+ }
309
+ const status =
310
+ typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
311
+ const restBinding = isRestBinding(req, mountPath)
312
+ if (status >= 500) logger.warn(`[a2a] request failed: ${errorMessage(error)}`)
313
+ const isParseError = error?.type === 'entity.parse.failed'
314
+ const isTooLarge = error?.type === 'entity.too.large'
315
+ writeError(req, res, {
316
+ status: isTooLarge ? 413 : isParseError ? 400 : status,
317
+ code: isTooLarge ? 'RESOURCE_EXHAUSTED' : isParseError ? 'INVALID_ARGUMENT' : status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
318
+ message: isTooLarge
319
+ ? `Request body exceeds limits.maxRequestBytes (${config.limits.maxRequestBytes} bytes)`
320
+ : isParseError
321
+ ? 'Invalid JSON payload'
322
+ : errorMessage(error),
323
+ restBinding,
324
+ rpcCode: isParseError ? -32700 : isTooLarge ? -32600 : -32603,
325
+ })
326
+ void next
327
+ })
328
+
329
+ const callApp = (req, res) => {
330
+ try {
331
+ app(req, res)
332
+ } catch (error) {
333
+ logger.warn(`[a2a] transport failure: ${errorMessage(error)}`)
334
+ if (!res.headersSent) {
335
+ res.statusCode = 500
336
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
337
+ res.end(JSON.stringify({ error: { code: 500, status: 'INTERNAL', message: 'internal error' } }))
338
+ } else if (!res.writableEnded) {
339
+ res.end()
340
+ }
341
+ }
342
+ }
343
+
344
+ const routeDisposers = []
345
+ const registerRoute = (route, label, { optional = false } = {}) => {
346
+ try {
347
+ routeDisposers.push(webServer.register({ ...route, handler: callApp }))
348
+ } catch (error) {
349
+ if (optional) {
350
+ logger.warn(`[a2a] cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
351
+ return
352
+ }
353
+ throw new Error(`a2a: cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
354
+ }
355
+ }
356
+
357
+ registerRoute({ kind: 'exact', path: config.server.cardPath }, 'agent card')
358
+ registerRoute({ kind: 'prefix', path: mountPath }, 'A2A endpoint')
359
+
360
+ const sweepTimer = setInterval(() => {
361
+ try {
362
+ taskStore.sweep()
363
+ } catch (error) {
364
+ logger.warn(`[a2a] task sweep failed: ${errorMessage(error)}`)
365
+ }
366
+ if (config.files.enabled) {
367
+ runtime
368
+ .resolveCwd()
369
+ .then((cwd) =>
370
+ sweepAttachmentDirectories({
371
+ cwd,
372
+ directory: config.files.directory,
373
+ retentionMs: config.files.retentionMs,
374
+ logger,
375
+ }),
376
+ )
377
+ .then(({ removed }) => {
378
+ if (removed > 0) logger.info(`removed ${removed} stale attachment director${removed === 1 ? 'y' : 'ies'}`)
379
+ })
380
+ .catch((error) => logger.warn(`[a2a] attachment sweep failed: ${errorMessage(error)}`))
381
+ }
382
+ }, config.tasks.sweepIntervalMs)
383
+ sweepTimer.unref?.()
384
+ runtime.start()
385
+
386
+ 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
+ )
392
+ logger.info(
393
+ `auth=${authenticator.describe().mode} streaming=${config.server.streaming} ` +
394
+ `push=${config.server.pushNotifications} extendedCard=${extendedCard}`,
395
+ )
396
+
397
+ let released = false
398
+ const unmount = async () => {
399
+ if (released) return
400
+ released = true
401
+ clearInterval(sweepTimer)
402
+ for (const dispose of routeDisposers.splice(0).reverse()) {
403
+ try {
404
+ dispose()
405
+ } catch (error) {
406
+ logger.warn(`[a2a] route disposal failed: ${errorMessage(error)}`)
407
+ }
408
+ }
409
+ await executor.dispose()
410
+ await runtime.shutdown()
411
+ this.#mounted = undefined
412
+ this.#unmount = undefined
413
+ logger.info('[a2a] server stopped')
414
+ }
415
+ this.#unmount = unmount
416
+ return unmount
417
+ }
418
+
419
+ #health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }) {
420
+ const config = this.#config
421
+ const mounted = this.#mounted
422
+ return {
423
+ status: 'ok',
424
+ protocolVersion: A2A_PROTOCOL_VERSION,
425
+ name: config.card.name,
426
+ version: config.card.version,
427
+ publicUrl: publicBaseUrl,
428
+ uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
429
+ interfaces: [
430
+ { protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
431
+ { protocolBinding: 'HTTP+JSON', url: `${publicBaseUrl}${config.server.mountPath}/rest` },
432
+ ],
433
+ capabilities: {
434
+ streaming: config.server.streaming,
435
+ pushNotifications: config.server.pushNotifications,
436
+ extendedAgentCard: extendedCard,
437
+ },
438
+ auth: authenticator.describe(),
439
+ signing: config.signing.enabled
440
+ ? { enabled: true, algorithm: config.signing.algorithm, keyId: config.signing.keyId }
441
+ : { enabled: false },
442
+ tasks: taskStore.stats(),
443
+ agents: runtime.stats(),
444
+ concurrency: executor.concurrency(),
445
+ }
446
+ }
447
+
448
+ /** Release service-wide resources. Safe to call multiple times. */
449
+ async dispose() {
450
+ if (this.#disposed) return
451
+ this.#disposed = true
452
+ const unmount = this.#unmount
453
+ this.#unmount = undefined
454
+ if (unmount !== undefined) await unmount()
455
+ }
456
+ }