@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.
- package/LICENSE +21 -0
- package/README.md +467 -2
- package/client.js +927 -0
- package/cordis.patch.yml +26 -0
- package/icon.svg +15 -0
- package/index.js +59 -0
- package/lib/auth.js +196 -0
- package/lib/bridge.js +518 -0
- package/lib/card.js +214 -0
- package/lib/client-discovery.js +58 -0
- package/lib/config.js +576 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +391 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +656 -0
- package/lib/store.js +288 -0
- package/lib/util.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
package/lib/server.js
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
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.
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* @module @ai_mind/dsh-a2a/server
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import { isIP } from 'node:net'
|
|
20
|
+
import express from 'express'
|
|
21
|
+
import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
|
|
22
|
+
import {
|
|
23
|
+
DefaultPushNotificationSender,
|
|
24
|
+
DefaultRequestHandler,
|
|
25
|
+
InMemoryPushNotificationStore,
|
|
26
|
+
} from '@a2a-js/sdk/server'
|
|
27
|
+
import { agentCardHandler, jsonRpcHandler, restHandler } from '@a2a-js/sdk/server/express'
|
|
28
|
+
import { buildAgentCard, buildExtendedCard, createCardSigner, isAuthEnabled } from './card.js'
|
|
29
|
+
import { createAuthenticator } from './auth.js'
|
|
30
|
+
import { createPanelRouter } from './panel.js'
|
|
31
|
+
import { A2ATaskStore, GuardedPushNotificationStore } from './store.js'
|
|
32
|
+
import { HarnessAgentExecutor } from './executor.js'
|
|
33
|
+
import { AgentRuntime } from './bridge.js'
|
|
34
|
+
import { errorMessage, trimTrailingSlash } from './util.js'
|
|
35
|
+
import { sweepAttachmentDirectories } from './files.js'
|
|
36
|
+
import { resolveCredentials } from './config.js'
|
|
37
|
+
|
|
38
|
+
const JSON_CONTENT_TYPE = 'application/json'
|
|
39
|
+
const A2A_CONTENT_TYPE = 'application/a2a+json'
|
|
40
|
+
|
|
41
|
+
/** Minimal structured logger over the Cordis logger. */
|
|
42
|
+
function createLogger(ctx) {
|
|
43
|
+
const render = (args) => ['[a2a]', ...args.map((value) => (typeof value === 'string' ? value : errorMessage(value)))].join(' ')
|
|
44
|
+
const emit = (level, args) => {
|
|
45
|
+
try {
|
|
46
|
+
const logger = ctx.logger
|
|
47
|
+
if (typeof logger?.[level] === 'function') logger[level](render(args))
|
|
48
|
+
else if (typeof logger?.log === 'function') logger.log(render(args))
|
|
49
|
+
} catch {
|
|
50
|
+
/* logging must never fail a request */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
info: (...args) => emit('info', args),
|
|
55
|
+
warn: (...args) => emit('warn', args),
|
|
56
|
+
error: (...args) => emit('error', args),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
+
*/
|
|
72
|
+
function resolvePublicBase(config, webServer, logger) {
|
|
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
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const origin = webServer.getOrigin()
|
|
87
|
+
if (typeof origin === 'string' && origin !== '') return trimTrailingSlash(origin)
|
|
88
|
+
} catch {
|
|
89
|
+
/* not listening yet; fall through */
|
|
90
|
+
}
|
|
91
|
+
const port = Number(webServer.port)
|
|
92
|
+
if (Number.isInteger(port) && port > 0) {
|
|
93
|
+
logger.warn(`[a2a] webServer.getOrigin() unavailable; advertising http://127.0.0.1:${port}`)
|
|
94
|
+
return `http://127.0.0.1:${port}`
|
|
95
|
+
}
|
|
96
|
+
logger.warn('[a2a] cannot determine a public origin; set server.publicUrl in the plugin config')
|
|
97
|
+
return 'http://127.0.0.1'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @returns {boolean} whether the request targets the REST binding. */
|
|
101
|
+
function isRestBinding(req, mountPath) {
|
|
102
|
+
const path = String(req.originalUrl ?? req.url ?? '')
|
|
103
|
+
return path.startsWith(`${mountPath}/rest`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Build the CORS middleware for the configured policy. */
|
|
107
|
+
function createCors(config) {
|
|
108
|
+
const cors = config.server.cors
|
|
109
|
+
return (req, res, next) => {
|
|
110
|
+
if (!cors.enabled) {
|
|
111
|
+
next()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const origin = req.headers.origin
|
|
115
|
+
const wildcard = cors.origins.includes('*')
|
|
116
|
+
const allowed = wildcard ? '*' : origin !== undefined && cors.origins.includes(origin) ? origin : null
|
|
117
|
+
if (allowed !== null) {
|
|
118
|
+
res.setHeader('Access-Control-Allow-Origin', allowed)
|
|
119
|
+
if (!wildcard) res.setHeader('Vary', 'Origin')
|
|
120
|
+
if (cors.allowCredentials) res.setHeader('Access-Control-Allow-Credentials', 'true')
|
|
121
|
+
res.setHeader('Access-Control-Expose-Headers', 'A2A-Extensions, A2A-Version, ETag')
|
|
122
|
+
}
|
|
123
|
+
if (req.method === 'OPTIONS') {
|
|
124
|
+
if (allowed !== null) {
|
|
125
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
|
126
|
+
res.setHeader(
|
|
127
|
+
'Access-Control-Allow-Headers',
|
|
128
|
+
`authorization, content-type, a2a-version, a2a-extensions, ${config.auth.headerName}`,
|
|
129
|
+
)
|
|
130
|
+
res.setHeader('Access-Control-Max-Age', String(cors.maxAgeSeconds))
|
|
131
|
+
}
|
|
132
|
+
res.statusCode = 204
|
|
133
|
+
res.end()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
next()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Write one error body in the shape of the binding the caller used. */
|
|
141
|
+
function writeError(req, res, { status, code, message, restBinding, rpcCode = -32603 }) {
|
|
142
|
+
if (res.headersSent) {
|
|
143
|
+
res.end()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
res.statusCode = status
|
|
147
|
+
res.setHeader('Content-Type', restBinding ? A2A_CONTENT_TYPE : JSON_CONTENT_TYPE)
|
|
148
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
149
|
+
const details = [
|
|
150
|
+
{
|
|
151
|
+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
152
|
+
reason: code,
|
|
153
|
+
domain: 'a2a-protocol.org',
|
|
154
|
+
metadata: { path: String(req.originalUrl ?? req.url ?? '') },
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
if (restBinding) {
|
|
158
|
+
res.end(JSON.stringify({ error: { code: status, status: code, message, details } }))
|
|
159
|
+
} else {
|
|
160
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: rpcCode, message, data: details } }))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One mounted A2A deployment. Mounting is synchronous so route registration
|
|
166
|
+
* can participate in Cordis effects; only request handling touches async
|
|
167
|
+
* agent work. The standalone listener is created here and reports its bind
|
|
168
|
+
* result through {@link A2AService.serverStatus}.
|
|
169
|
+
*/
|
|
170
|
+
export class A2AService {
|
|
171
|
+
#ctx
|
|
172
|
+
#config
|
|
173
|
+
#logger
|
|
174
|
+
#mounted = undefined
|
|
175
|
+
#unmount = undefined
|
|
176
|
+
#standalone = undefined
|
|
177
|
+
#disposed = false
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {object} ctx owning Cordis context
|
|
181
|
+
* @param {Readonly<object>} config normalized configuration
|
|
182
|
+
*/
|
|
183
|
+
constructor(ctx, config) {
|
|
184
|
+
this.#ctx = ctx
|
|
185
|
+
this.#config = config
|
|
186
|
+
this.#logger = createLogger(ctx)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @returns {object} the structured logger used by every component */
|
|
190
|
+
get logger() {
|
|
191
|
+
return this.#logger
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
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.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} webServer the `webServer` service
|
|
229
|
+
* @returns {() => Promise<void>} disposer that unregisters routes, closes the
|
|
230
|
+
* standalone listener and drains work
|
|
231
|
+
*/
|
|
232
|
+
mount(webServer) {
|
|
233
|
+
if (this.#mounted !== undefined) throw new Error('a2a: already mounted')
|
|
234
|
+
if (this.#disposed) throw new Error('a2a: service is disposed')
|
|
235
|
+
const config = this.#config
|
|
236
|
+
const logger = this.#logger
|
|
237
|
+
const mountPath = config.server.mountPath
|
|
238
|
+
const standalone = config.server.port !== null
|
|
239
|
+
|
|
240
|
+
const credentials = resolveCredentials(config)
|
|
241
|
+
const publicBaseUrl = resolvePublicBase(config, webServer, logger)
|
|
242
|
+
const authEnabled = isAuthEnabled(config, credentials)
|
|
243
|
+
let extendedCard = config.server.extendedCard
|
|
244
|
+
if (extendedCard && !authEnabled) {
|
|
245
|
+
logger.warn(
|
|
246
|
+
'[a2a] server.extendedCard requires authentication; the extended Agent Card stays disabled because auth.mode is "none"',
|
|
247
|
+
)
|
|
248
|
+
extendedCard = false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const effective = {
|
|
252
|
+
...config,
|
|
253
|
+
server: { ...config.server, extendedCard, pushNotifications: config.server.pushNotifications },
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const cardOptions = { publicBaseUrl, credentials }
|
|
257
|
+
const baseCard = buildAgentCard(effective, cardOptions)
|
|
258
|
+
const extendedAgentCard = buildExtendedCard(effective, cardOptions)
|
|
259
|
+
const signer = createCardSigner(config)
|
|
260
|
+
|
|
261
|
+
const runtime = new AgentRuntime({ ctx: this.#ctx, config, logger })
|
|
262
|
+
const executor = new HarnessAgentExecutor({ runtime, config, logger })
|
|
263
|
+
const taskStore = new A2ATaskStore({ ...config.tasks, logger })
|
|
264
|
+
|
|
265
|
+
const pushStore = config.server.pushNotifications
|
|
266
|
+
? new GuardedPushNotificationStore(new InMemoryPushNotificationStore(), {
|
|
267
|
+
allowPrivateTargets: config.push.allowPrivateTargets,
|
|
268
|
+
maxConfigsPerTask: 10,
|
|
269
|
+
logger,
|
|
270
|
+
})
|
|
271
|
+
: undefined
|
|
272
|
+
const pushSender =
|
|
273
|
+
pushStore === undefined
|
|
274
|
+
? undefined
|
|
275
|
+
: new DefaultPushNotificationSender(pushStore, { timeout: config.push.timeoutMs })
|
|
276
|
+
|
|
277
|
+
const requestHandler = new DefaultRequestHandler(
|
|
278
|
+
baseCard,
|
|
279
|
+
taskStore,
|
|
280
|
+
executor,
|
|
281
|
+
undefined,
|
|
282
|
+
pushStore,
|
|
283
|
+
pushSender,
|
|
284
|
+
extendedCard ? async () => extendedAgentCard : undefined,
|
|
285
|
+
signer ?? undefined,
|
|
286
|
+
{ validateInputModes: true },
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const authenticator = createAuthenticator({
|
|
290
|
+
config,
|
|
291
|
+
credentials,
|
|
292
|
+
logger,
|
|
293
|
+
isRestBinding: (req) => isRestBinding(req, mountPath),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const cardRouter = agentCardHandler({
|
|
297
|
+
agentCardProvider: requestHandler,
|
|
298
|
+
cache: { maxAge: config.server.cardCacheSeconds },
|
|
299
|
+
})
|
|
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,
|
|
314
|
+
})
|
|
315
|
+
|
|
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()
|
|
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
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const a2aErrorHandler = (error, req, res, next) => {
|
|
346
|
+
if (res.headersSent) {
|
|
347
|
+
res.end()
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
const status =
|
|
351
|
+
typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
|
|
352
|
+
const restBinding = isRestBinding(req, mountPath)
|
|
353
|
+
if (status >= 500) logger.warn(`[a2a] request failed: ${errorMessage(error)}`)
|
|
354
|
+
const isParseError = error?.type === 'entity.parse.failed'
|
|
355
|
+
const isTooLarge = error?.type === 'entity.too.large'
|
|
356
|
+
writeError(req, res, {
|
|
357
|
+
status: isTooLarge ? 413 : isParseError ? 400 : status,
|
|
358
|
+
code: isTooLarge ? 'RESOURCE_EXHAUSTED' : isParseError ? 'INVALID_ARGUMENT' : status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
|
|
359
|
+
message: isTooLarge
|
|
360
|
+
? `Request body exceeds limits.maxRequestBytes (${config.limits.maxRequestBytes} bytes)`
|
|
361
|
+
: isParseError
|
|
362
|
+
? 'Invalid JSON payload'
|
|
363
|
+
: errorMessage(error),
|
|
364
|
+
restBinding,
|
|
365
|
+
rpcCode: isParseError ? -32700 : isTooLarge ? -32600 : -32603,
|
|
366
|
+
})
|
|
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
|
+
})
|
|
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)
|
|
427
|
+
|
|
428
|
+
const wrap = (app) => (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
app(req, res)
|
|
431
|
+
} catch (error) {
|
|
432
|
+
logger.warn(`[a2a] transport failure: ${errorMessage(error)}`)
|
|
433
|
+
if (!res.headersSent) {
|
|
434
|
+
res.statusCode = 500
|
|
435
|
+
res.setHeader('Content-Type', JSON_CONTENT_TYPE)
|
|
436
|
+
res.end(JSON.stringify({ error: { code: 500, status: 'INTERNAL', message: 'internal error' } }))
|
|
437
|
+
} else if (!res.writableEnded) {
|
|
438
|
+
res.end()
|
|
439
|
+
}
|
|
440
|
+
}
|
|
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
|
+
}
|
|
460
|
+
|
|
461
|
+
const routeDisposers = []
|
|
462
|
+
let standaloneState
|
|
463
|
+
const registerRoute = (route, handler, label, { optional = false } = {}) => {
|
|
464
|
+
try {
|
|
465
|
+
routeDisposers.push(webServer.register({ ...route, handler }))
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (optional) {
|
|
468
|
+
logger.warn(`[a2a] cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
throw new Error(`a2a: cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
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
|
+
}
|
|
539
|
+
|
|
540
|
+
const sweepTimer = setInterval(() => {
|
|
541
|
+
try {
|
|
542
|
+
taskStore.sweep()
|
|
543
|
+
} catch (error) {
|
|
544
|
+
logger.warn(`[a2a] task sweep failed: ${errorMessage(error)}`)
|
|
545
|
+
}
|
|
546
|
+
if (config.files.enabled) {
|
|
547
|
+
runtime
|
|
548
|
+
.resolveCwd()
|
|
549
|
+
.then((cwd) =>
|
|
550
|
+
sweepAttachmentDirectories({
|
|
551
|
+
cwd,
|
|
552
|
+
directory: config.files.directory,
|
|
553
|
+
retentionMs: config.files.retentionMs,
|
|
554
|
+
logger,
|
|
555
|
+
}),
|
|
556
|
+
)
|
|
557
|
+
.then(({ removed }) => {
|
|
558
|
+
if (removed > 0) logger.info(`removed ${removed} stale attachment director${removed === 1 ? 'y' : 'ies'}`)
|
|
559
|
+
})
|
|
560
|
+
.catch((error) => logger.warn(`[a2a] attachment sweep failed: ${errorMessage(error)}`))
|
|
561
|
+
}
|
|
562
|
+
}, config.tasks.sweepIntervalMs)
|
|
563
|
+
sweepTimer.unref?.()
|
|
564
|
+
runtime.start()
|
|
565
|
+
|
|
566
|
+
const startedAt = Date.now()
|
|
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
|
+
}
|
|
582
|
+
logger.info(
|
|
583
|
+
`auth=${authenticator.describe().mode} streaming=${config.server.streaming} ` +
|
|
584
|
+
`push=${config.server.pushNotifications} extendedCard=${extendedCard}`,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
let released = false
|
|
588
|
+
const unmount = async () => {
|
|
589
|
+
if (released) return
|
|
590
|
+
released = true
|
|
591
|
+
clearInterval(sweepTimer)
|
|
592
|
+
for (const dispose of routeDisposers.splice(0).reverse()) {
|
|
593
|
+
try {
|
|
594
|
+
dispose()
|
|
595
|
+
} catch (error) {
|
|
596
|
+
logger.warn(`[a2a] route disposal failed: ${errorMessage(error)}`)
|
|
597
|
+
}
|
|
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
|
+
}
|
|
608
|
+
await executor.dispose()
|
|
609
|
+
await runtime.shutdown()
|
|
610
|
+
this.#mounted = undefined
|
|
611
|
+
this.#unmount = undefined
|
|
612
|
+
logger.info('[a2a] server stopped')
|
|
613
|
+
}
|
|
614
|
+
this.#unmount = unmount
|
|
615
|
+
return unmount
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }) {
|
|
619
|
+
const config = this.#config
|
|
620
|
+
const mounted = this.#mounted
|
|
621
|
+
return {
|
|
622
|
+
status: 'ok',
|
|
623
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
624
|
+
name: config.card.name,
|
|
625
|
+
version: config.card.version,
|
|
626
|
+
publicUrl: publicBaseUrl,
|
|
627
|
+
uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
|
|
628
|
+
server: this.serverStatus(),
|
|
629
|
+
interfaces: [
|
|
630
|
+
{ protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
|
|
631
|
+
{ protocolBinding: 'HTTP+JSON', url: `${publicBaseUrl}${config.server.mountPath}/rest` },
|
|
632
|
+
],
|
|
633
|
+
capabilities: {
|
|
634
|
+
streaming: config.server.streaming,
|
|
635
|
+
pushNotifications: config.server.pushNotifications,
|
|
636
|
+
extendedAgentCard: extendedCard,
|
|
637
|
+
},
|
|
638
|
+
auth: authenticator.describe(),
|
|
639
|
+
signing: config.signing.enabled
|
|
640
|
+
? { enabled: true, algorithm: config.signing.algorithm, keyId: config.signing.keyId }
|
|
641
|
+
: { enabled: false },
|
|
642
|
+
tasks: taskStore.stats(),
|
|
643
|
+
agents: runtime.stats(),
|
|
644
|
+
concurrency: executor.concurrency(),
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Release service-wide resources. Safe to call multiple times. */
|
|
649
|
+
async dispose() {
|
|
650
|
+
if (this.#disposed) return
|
|
651
|
+
this.#disposed = true
|
|
652
|
+
const unmount = this.#unmount
|
|
653
|
+
this.#unmount = undefined
|
|
654
|
+
if (unmount !== undefined) await unmount()
|
|
655
|
+
}
|
|
656
|
+
}
|