dsh-a2a-groups 0.0.0-stage → 0.1.2

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,1304 @@
1
+ /**
2
+ * The A2A 1.0 HTTP endpoint: routing, authentication, version gate, dispatch.
3
+ *
4
+ * This module is the membrane. On one side is a JSON-RPC speaker that knows only
5
+ * A2A; on the other is a skill registry whose entries run inside this DSH host.
6
+ * Everything risky about that membrane is decided here, once:
7
+ * who may call (token or loopback), which protocol they speak, how big a body
8
+ * they may send, how long a skill may run, and what a peer sees when it all goes
9
+ * wrong.
10
+ *
11
+ * Boundaries:
12
+ * - It never imports DSH. Host capability arrives as an injected `registrar`, so the same
13
+ * code serves a DSH plugin and a standalone Node host — see `createServer`'s doc.
14
+ * - It never formats a reply. Skills produce `{text, artifacts, finalState}`;
15
+ * `wire.js` turns that into 1.0 envelopes. A handler that hand-built JSON here
16
+ * would be the first place a protocol field could drift.
17
+ * - The agent card is public on purpose. A2A discovery happens before the caller
18
+ * has credentials; requiring a token for the card would make the endpoint
19
+ * undiscoverable through the gateways this plugin exists to talk to.
20
+ *
21
+ * @module dsh-a2a/server
22
+ */
23
+
24
+ import {
25
+ RPC_ERRORS,
26
+ PROTOCOL_VERSION,
27
+ IMPLIED_LEGACY_VERSION,
28
+ artifactUpdate,
29
+ makeArtifact,
30
+ redactAll,
31
+ rpcError,
32
+ rpcResult,
33
+ safeEqual,
34
+ statusUpdate,
35
+ textOfMessage,
36
+ toWireTask,
37
+ writeComment,
38
+ writeSse,
39
+ writeSseHead,
40
+ } from './wire.js';
41
+ import { A2A_PATH, CARD_ALIAS_PATH, CARD_PATH, buildCard, resolveAgentUrl } from './card.js';
42
+
43
+ /** @typedef {import('./wire.js').TaskRow} TaskRow */
44
+
45
+ /**
46
+ * Something with the usual three log levels, any of which may be absent.
47
+ *
48
+ * Declared here because `logger?.warn` is called on an injected value that this
49
+ * module never constructs: naming the shape is what tells a reader the fields
50
+ * are optional on purpose rather than forgotten.
51
+ *
52
+ * @typedef {object} A2aLogger
53
+ * @property {(message: string) => void} [info]
54
+ * @property {(message: string) => void} [warn]
55
+ * @property {(message: string) => void} [debug]
56
+ */
57
+
58
+ /**
59
+ * One `node:http` request, reduced to what this module actually reads.
60
+ *
61
+ * Narrower than the real `IncomingMessage` on purpose: a checker that knows this
62
+ * shape can verify every property access here, and any handler that reaches for
63
+ * a field outside this list becomes a visible decision instead of a silent one.
64
+ *
65
+ * @typedef {object} A2aRequest
66
+ * @property {string} [method] - HTTP verb.
67
+ * @property {string} [url] - request target.
68
+ * @property {Record<string, string|string[]|undefined>} [headers] - lower-cased header bag.
69
+ * @property {{remoteAddress?: string}} [socket] - peer address, for the loopback gate.
70
+ * @property {boolean} [readableEnded] - whether the body was fully consumed.
71
+ * @property {(event: string, fn: () => void) => void} [once] - one-shot listener.
72
+ * @property {(event: string, fn: () => void) => void} [off] - listener removal.
73
+ * @property {AsyncIterable<Buffer|string>} [body] - the request IS an async iterable of body chunks; see `readBody`.
74
+ */
75
+
76
+ /**
77
+ * One `node:http` response, reduced to what this module writes.
78
+ *
79
+ * @typedef {object} A2aResponse
80
+ * @property {number} [statusCode] - status to send.
81
+ * @property {boolean} [headersSent] - whether the head went out already.
82
+ * @property {boolean} [writableEnded] - whether `end()` was called.
83
+ * @property {boolean} [destroyed] - whether the socket is gone.
84
+ * @property {(name: string, value: string) => void} [setHeader] - header setter.
85
+ * @property {() => void} [flushHeaders] - force the head out, for SSE.
86
+ * @property {(chunk?: string) => boolean} [write] - body write.
87
+ * @property {(body?: string) => void} [end] - finish the response.
88
+ * @property {(event: string, fn: () => void) => void} [once]
89
+ * @property {(event: string, fn: () => void) => void} [off]
90
+ */
91
+
92
+ /**
93
+ * The four registrations this module needs from a route table.
94
+ *
95
+ * @typedef {object} A2aRegistrar
96
+ * @property {(route: {kind: 'exact'|'prefix', path: string, handler: Function}) => (() => void)} register
97
+ */
98
+
99
+ /**
100
+ * A JSON-RPC `params` bag, as the four supported methods shape it.
101
+ *
102
+ * Every field is optional because a malformed call is answered with a coded
103
+ * error, not a crash: the handlers check for themselves rather than trusting the
104
+ * shape. Declaring the fields still buys the important half — a handler that
105
+ * reads `params.id` when the caller promised `params.message` now reads a
106
+ * known-optional field instead of an unknown one.
107
+ *
108
+ * @typedef {object} A2aParams
109
+ * @property {A2aMessage} [message] - SendMessage / SendStreamingMessage payload.
110
+ * @property {string} [id] - GetTask / CancelTask target.
111
+ * @property {string} [taskId] - legacy alias for `id`.
112
+ * @property {string} [name] - 0.3 spelling of `id`.
113
+ */
114
+
115
+ /**
116
+ * One inbound A2A 1.0 message.
117
+ *
118
+ * @typedef {object} A2aMessage
119
+ * @property {'user'|'agent'} [role]
120
+ * @property {string} [messageId]
121
+ * @property {string} [contextId]
122
+ * @property {Array<{kind: string, text?: string}>} [parts]
123
+ * @property {Record<string, any>} [metadata]
124
+ */
125
+
126
+ /**
127
+ * What a skill registry must offer: dispatch by id, and a list for the card.
128
+ *
129
+ * @typedef {object} A2aSkillRegistry
130
+ * @property {(id: string) => any} get
131
+ * @property {() => any[]} list
132
+ */
133
+
134
+ /**
135
+ * One callable skill.
136
+ *
137
+ * @typedef {object} A2aSkill
138
+ * @property {string} id
139
+ * @property {string} [name]
140
+ * @property {string} [description]
141
+ * @property {(input: object, context: object) => Promise<object|string>} run
142
+ */
143
+
144
+ /**
145
+ * The task store this module drives.
146
+ *
147
+ * @typedef {object} A2aTaskStore
148
+ * @property {(input?: object) => any} create
149
+ * @property {(id: string, patch?: object) => any} update
150
+ * @property {(id: string, artifact: object) => any} attachArtifact
151
+ * @property {(id: string) => any} get
152
+ * @property {(id: string) => any} cancel
153
+ * @property {() => boolean} flush
154
+ * @property {number} [size]
155
+ */
156
+
157
+ /**
158
+ * The two shapes `admit()` can return: a runnable admission, or a refusal.
159
+ *
160
+ * Declared as one optional-laden type rather than `object` because the callers
161
+ * immediately narrow on `admitted.error`: with `object`, every `.task`/`.signal`
162
+ * read on the success branch was an error, and the refusal branch could not be
163
+ * distinguished from a success that merely lacked `error`.
164
+ *
165
+ * @typedef {object} A2aAdmission
166
+ * @property {TaskRow} [task] - the opened task row.
167
+ * @property {A2aSkill} [skill] - resolved skill.
168
+ * @property {string} [skillId]
169
+ * @property {string} [text] - flattened request text.
170
+ * @property {A2aMessage} [message] - the original 1.0 message.
171
+ * @property {AbortSignal} [signal] - timeout/disconnect cancellation.
172
+ * @property {Promise<any>} [timeout] - independent deadline rejection.
173
+ * @property {() => void} [cleanup] - clears the timer and listeners.
174
+ * @property {{code: number, message: string, data?: object}} [error] - refusal.
175
+ */
176
+
177
+ /**
178
+ * Arguments to `admit()`.
179
+ *
180
+ * @typedef {object} A2aAdmitInput
181
+ * @property {A2aResponse} res - the HTTP response, for the disconnect signal.
182
+ * @property {A2aParams} params - JSON-RPC params.
183
+ */
184
+
185
+ /**
186
+ * Operator endpoint: liveness plus an authenticated flush.
187
+ *
188
+ * Deliberately outside the A2A namespace: a monitor asking 'are you alive' should not
189
+ * have to look like an A2A peer, and a backup script should not have to build a
190
+ * JSON-RPC envelope to ask for a flush.
191
+ */
192
+ export const HEALTH_PATH = '/__health';
193
+
194
+ /** When this module was loaded, for the uptime an operator sees. */
195
+ const STARTED_AT = Date.now();
196
+
197
+ /** Methods this deployment answers. Anything else is `-32601`. */
198
+ const SUPPORTED_METHODS = Object.freeze([
199
+ 'SendMessage',
200
+ 'SendStreamingMessage',
201
+ 'GetTask',
202
+ 'CancelTask',
203
+ ]);
204
+
205
+ /** Method names that stream; used by the `streaming: false` gate. */
206
+ const STREAMING_METHODS = Object.freeze(new Set(['SendStreamingMessage']));
207
+
208
+ /** Header the protocol version travels in. */
209
+ const VERSION_HEADER = 'a2a-version';
210
+
211
+ /** Default request body ceiling (1 MiB): one text message, not a file upload. */
212
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
213
+
214
+ /** Default per-skill execution ceiling (10 min). */
215
+ const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000;
216
+
217
+ /** Keep-alive cadence for a long stream, below the usual 60 s proxy idle cut. */
218
+ const HEARTBEAT_MS = 15000;
219
+
220
+ /** Default skill when the caller names none. */
221
+ const DEFAULT_SKILL = 'session/ask';
222
+
223
+ /**
224
+ * The options `createServer` accepts.
225
+ *
226
+ * Kept as one named type rather than a pile of `@param` lines because the object
227
+ * is DESTRUCTURED in the signature: TypeScript only validates a destructured
228
+ * parameter against a single declared type, so per-property `@param` entries
229
+ * leave the parameter's shape `{}` and every read of `config`/`tasks`/`skills`
230
+ * reports "does not exist". Naming the type also makes the `profileProvider`
231
+ * and `onFlush` seams — the two injectable hooks a non-DSH host uses — visible
232
+ * in one place instead of being implicit.
233
+ *
234
+ * @typedef {object} A2aServerOptions
235
+ * @property {object} [ctx] - Host context; only `ctx.webServer` is read, as the default registrar.
236
+ * @property {A2aRegistrar} [registrar] - route table; defaults to `ctx?.webServer`.
237
+ * @property {string} [where] - where this server runs, for error messages.
238
+ * @property {A2aLogger} [logger] - logger with `info` / `warn` / `debug`.
239
+ * @property {object} [config] - normalized plugin config (§3 of the contract).
240
+ * @property {object} [deliverer] - from `createDeliverer`.
241
+ * @property {A2aTaskStore} [tasks] - a `TaskStore`.
242
+ * @property {A2aSkillRegistry} [skills] - skill registry `{get, list}`.
243
+ * @property {() => object} [profileProvider] - layer-B profile for the card, or a throw.
244
+ * @property {() => Promise<void>|void} [onFlush] - operator flush hook behind `/__health`.
245
+ */
246
+
247
+ /**
248
+ * Build the A2A server facade.
249
+ *
250
+ * The facade needs a way to add four routes and nothing else, so the route table is
251
+ * injected instead of reached for on `ctx`. That single indirection is what lets the
252
+ * SAME server code run in two very different homes:
253
+ *
254
+ * - inside DSH (a member instance, and the embedded host): pass
255
+ * `ctx.webServer` as `registrar`;
256
+ * - as a standalone Node program (a production group host): pass an object whose
257
+ * `register` attaches to a plain `http.Server`.
258
+ *
259
+ * @param {A2aServerOptions} options - server options.
260
+ * @returns {{attach: () => () => void, detach: () => void, card: () => object, info: () => object}} the facade.
261
+ */
262
+ export function createServer({
263
+ ctx,
264
+ registrar = /** @type {A2aRegistrar|undefined} */ (ctx?.webServer),
265
+ where = 'inside DSH',
266
+ logger,
267
+ config,
268
+ deliverer,
269
+ tasks,
270
+ skills,
271
+ profileProvider,
272
+ onFlush,
273
+ } = {}) {
274
+ const serverConfig = /** @type {any} */ (config)?.server ?? {};
275
+ const maxBodyBytes = positiveInt(serverConfig.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
276
+ const taskTimeoutMs = positiveInt(serverConfig.taskTimeoutMs, DEFAULT_TASK_TIMEOUT_MS);
277
+ const token = typeof serverConfig.token === 'string' ? serverConfig.token.trim() : '';
278
+ const streaming = serverConfig.streaming !== false;
279
+
280
+ /** @type {Array<() => void>} disposers for every registered route. */
281
+ let disposers = [];
282
+ let attached = false;
283
+
284
+ /**
285
+ * Attach every route to the Host webserver.
286
+ *
287
+ * Registration order carries no request-facing meaning (the webserver matches
288
+ * exact-first, then longest prefix), so the three registrations are listed
289
+ * longest-path-first purely for readability. The `/a2a/.well-known/` prefix
290
+ * route is what turns an unknown discovery path into a JSON 404 instead of the
291
+ * SPA's HTML shell, which would make a probing peer think it got a card.
292
+ *
293
+ * @returns {() => void} a disposer removing every route this call added.
294
+ */
295
+ function attach() {
296
+ if (attached) return detach;
297
+ // The route table comes from an injected registrar rather than from `ctx`: a
298
+ // group host runs as a standalone Node program with a plain `http.Server`, and a
299
+ // member instance runs inside DSH and uses `ctx.webServer`. Both pass the same
300
+ // four registrations, so the A2A surface is identical either way.
301
+ const register = registrar?.register;
302
+ if (typeof register !== 'function') {
303
+ throw new Error(`dsh-a2a: no route registrar was provided; the A2A endpoint cannot attach (${where})`);
304
+ }
305
+ if (token === '') {
306
+ warn('no server.token configured; the A2A endpoint accepts loopback callers only. Set config.server.token to expose it.');
307
+ }
308
+ disposers = [
309
+ register.call(registrar, { kind: 'exact', path: CARD_PATH, handler: handleCard }),
310
+ register.call(registrar, { kind: 'exact', path: CARD_ALIAS_PATH, handler: handleCard }),
311
+ register.call(registrar, { kind: 'prefix', path: `${A2A_PATH}/.well-known/`, handler: handleWellKnownFallback }),
312
+ register.call(registrar, { kind: 'exact', path: A2A_PATH, handler: handleRpc }),
313
+ // An operator needs two things a card cannot give: "are you alive" without
314
+ // speaking A2A, and "make sure what you have in memory is on disk before I copy
315
+ // the file". Both are authenticated — a flush is a write and must not be
316
+ // triggerable by anyone who can reach the port.
317
+ register.call(registrar, { kind: 'exact', path: HEALTH_PATH, handler: handleHealth }),
318
+ ];
319
+ attached = true;
320
+ info();
321
+ return detach;
322
+ }
323
+
324
+ /**
325
+ * Remove every route this facade registered.
326
+ *
327
+ * A disposed route must leave the Host clean: a plugin reload re-registers the
328
+ * same paths, and the webserver throws on a duplicate, so a missed disposer
329
+ * turns "reload the plugin" into a startup failure.
330
+ *
331
+ * @returns {void}
332
+ */
333
+ function detach() {
334
+ for (const dispose of disposers.reverse()) {
335
+ try {
336
+ if (typeof dispose === 'function') dispose();
337
+ } catch (error) {
338
+ warn('failed to dispose a route', error);
339
+ }
340
+ }
341
+ disposers = [];
342
+ attached = false;
343
+ }
344
+
345
+ /**
346
+ * Build the current agent card.
347
+ * @returns {object} the card document.
348
+ */
349
+ function card() {
350
+ return buildCard({
351
+ config,
352
+ skills,
353
+ url: resolveAgentUrl({ config, origin: originProvider() }),
354
+ name: /** @type {any} */ (config)?.agent?.name,
355
+ description: /** @type {any} */ (config)?.agent?.description,
356
+ version: /** @type {any} */ (config)?.agent?.version,
357
+ // Layer B: derived per read, never stored. A provider that throws must not
358
+ // take the card down — a peer needs the skills list even if the identity
359
+ // aggregation is unhappy, so a failed profile degrades to "no extension".
360
+ profile: readProfile(profileProvider),
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Describe this endpoint for a human or for a diagnostics tool.
366
+ * @returns {object} a credential-free summary.
367
+ */
368
+ function info() {
369
+ const summary = {
370
+ url: resolveAgentUrl({ config, origin: originProvider() }) || A2A_PATH,
371
+ path: A2A_PATH,
372
+ card: CARD_PATH,
373
+ attached,
374
+ streaming,
375
+ tokenConfigured: token !== '',
376
+ loopbackOnly: token === '',
377
+ maxBodyBytes,
378
+ taskTimeoutMs,
379
+ protocolVersion: PROTOCOL_VERSION,
380
+ methods: [...SUPPORTED_METHODS],
381
+ skills: typeof skills?.list === 'function' ? skills.list().map((skill) => skill?.id).filter(Boolean) : [],
382
+ tasks: typeof tasks?.size === 'number' ? tasks.size : 0,
383
+ };
384
+ if (typeof logger?.debug === 'function') logger.debug(`dsh-a2a: server ${attached ? 'attached' : 'detached'} at ${summary.url}`);
385
+ return summary;
386
+ }
387
+
388
+ /**
389
+ * Serve the agent card.
390
+ * @param {A2aRequest} req - `node:http` request.
391
+ * @param {A2aResponse} res - `node:http` response.
392
+ * @returns {Promise<void>} resolves when the response is written.
393
+ */
394
+ async function handleCard(req, res) {
395
+ if (!isReadMethod(req?.method)) {
396
+ writeJson(res, 405, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'the agent card is served over GET'), { allow: 'GET, HEAD' });
397
+ return;
398
+ }
399
+ writeJson(res, 200, card());
400
+ }
401
+
402
+ /**
403
+ * Answer any other `/.well-known/` path with a JSON 404.
404
+ *
405
+ * This route exists to keep a probing peer on the wire format it expects. The
406
+ * SPA fallback would otherwise answer 200 with HTML, and a client that parsed
407
+ * the body would report a confusing schema error instead of "no such document".
408
+ *
409
+ * @param {A2aRequest} req - `node:http` request.
410
+ * @param {A2aResponse} res - `node:http` response.
411
+ * @returns {Promise<void>} resolves when the response is written.
412
+ */
413
+ async function handleWellKnownFallback(req, res) {
414
+ const path = pathOf(req);
415
+ writeJson(res, 404, rpcError(null, RPC_ERRORS.INVALID_REQUEST, `no A2A discovery document at ${path}`));
416
+ }
417
+
418
+ /**
419
+ * Operator endpoint: liveness, and a flush before a backup copies the file.
420
+ *
421
+ * Two things an operator cannot get from the card:
422
+ *
423
+ * - "are you alive" without speaking A2A (a monitor should not have to build a
424
+ * JSON-RPC envelope to ask a yes/no question);
425
+ * - "put what you hold in memory on disk now", so `cp groups.json` is guaranteed
426
+ * to capture the latest state rather than whatever was flushed last.
427
+ *
428
+ * Both are behind the same token as `/a2a`. A flush is a write, and a write endpoint
429
+ * that anyone on the network can trigger is a way to make the host do disk I/O on
430
+ * demand. `POST` is required for the flush so a crawler or a browser prefetch cannot
431
+ * trigger it by accident.
432
+ *
433
+ * @param {A2aRequest} req - `node:http` request.
434
+ * @param {A2aResponse} res - `node:http` response.
435
+ * @returns {Promise<void>} resolves when the response is written.
436
+ */
437
+ async function handleHealth(req, res) {
438
+ const verdict = authenticate(req);
439
+ if (verdict.ok !== true) {
440
+ writeJson(res, 401, { error: 'unauthorized', reason: verdict.reason }, { 'www-authenticate': 'Bearer' });
441
+ return;
442
+ }
443
+ const flushRequested = String(req?.method ?? 'GET').toUpperCase() === 'POST';
444
+ if (!isReadMethod(req?.method) && !flushRequested) {
445
+ writeJson(res, 405, { error: 'method_not_allowed' }, { allow: 'GET, HEAD, POST' });
446
+ return;
447
+ }
448
+ let flushed = false;
449
+ let flushError;
450
+ if (flushRequested && typeof onFlush === 'function') {
451
+ try {
452
+ await onFlush();
453
+ flushed = true;
454
+ } catch (error) {
455
+ // Report the failure instead of a 500: a backup script can then decide to
456
+ // skip the copy, which is far better than copying a stale file silently.
457
+ flushError = error instanceof Error ? error.message : String(error);
458
+ }
459
+ }
460
+ writeJson(res, flushError === undefined ? 200 : 500, {
461
+ ok: flushError === undefined,
462
+ agent: typeof config?.agent?.name === 'string' ? config.agent.name : undefined,
463
+ version: PROTOCOL_VERSION,
464
+ uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
465
+ skills: typeof skills?.list === 'function' ? skills.list().length : 0,
466
+ ...(flushRequested ? { flushed, ...(flushError === undefined ? {} : { flushError }) } : {}),
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Handle one JSON-RPC call.
472
+ *
473
+ * Order is deliberate: auth, then size, then version, then parse. Authenticating
474
+ * before reading the body keeps an unauthenticated peer from making this Host
475
+ * buffer a megabyte, and versioning before parsing means a 0.3 caller gets a
476
+ * precise `-32003` instead of an `-32600` that hides the real problem.
477
+ *
478
+ * @param {A2aRequest} req - `node:http` request.
479
+ * @param {A2aResponse} res - `node:http` response.
480
+ * @returns {Promise<void>} resolves when the response is written.
481
+ */
482
+ async function handleRpc(req, res) {
483
+ res?.setHeader?.('A2A-Version', PROTOCOL_VERSION);
484
+ if (!isWriteMethod(req?.method)) {
485
+ writeJson(res, 405, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'the JSON-RPC endpoint is POST-only'), { allow: 'POST' });
486
+ return;
487
+ }
488
+ const auth = authenticate(req);
489
+ if (!auth.ok) {
490
+ writeJson(res, 401, rpcError(null, RPC_ERRORS.UNAUTHENTICATED, auth.reason), { 'www-authenticate': 'Bearer' });
491
+ return;
492
+ }
493
+ let raw;
494
+ try {
495
+ raw = await readBody(req, maxBodyBytes);
496
+ } catch (error) {
497
+ if (error?.code === 'body_too_large') {
498
+ writeJson(res, 413, rpcError(null, RPC_ERRORS.INVALID_REQUEST, `request body exceeds ${maxBodyBytes} bytes`));
499
+ return;
500
+ }
501
+ writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'could not read the request body'));
502
+ return;
503
+ }
504
+ let envelope;
505
+ try {
506
+ envelope = JSON.parse(raw);
507
+ } catch {
508
+ writeJson(res, 400, rpcError(null, RPC_ERRORS.PARSE, 'request body is not valid JSON'));
509
+ return;
510
+ }
511
+ if (Array.isArray(envelope)) {
512
+ // A2A 1.0 is one call per request; a batch has no single task to bind the
513
+ // reply to, so answering anything but an error would invent semantics.
514
+ writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'batch requests are not supported'));
515
+ return;
516
+ }
517
+ if (envelope === null || typeof envelope !== 'object') {
518
+ writeJson(res, 400, rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'request must be a JSON object'));
519
+ return;
520
+ }
521
+ const id = envelope.id ?? null;
522
+ if (envelope.jsonrpc !== '2.0') {
523
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_REQUEST, 'jsonrpc must be "2.0"'));
524
+ return;
525
+ }
526
+ const version = versionOf(req);
527
+ if (version !== PROTOCOL_VERSION) {
528
+ const detail = version === IMPLIED_LEGACY_VERSION
529
+ ? `missing A2A-Version header implies ${IMPLIED_LEGACY_VERSION}`
530
+ : `requested ${version}`;
531
+ writeJson(
532
+ res,
533
+ 200,
534
+ rpcError(id, RPC_ERRORS.VERSION_UNSUPPORTED, `this agent speaks A2A ${PROTOCOL_VERSION} only (${detail})`, {
535
+ supported: [PROTOCOL_VERSION],
536
+ }),
537
+ );
538
+ return;
539
+ }
540
+ const method = envelope.method;
541
+ if (typeof method !== 'string' || method === '') {
542
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_REQUEST, 'method must be a non-empty string'));
543
+ return;
544
+ }
545
+ if (!SUPPORTED_METHODS.includes(method)) {
546
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unknown method "${method}"`, { method }));
547
+ return;
548
+ }
549
+ if (!streaming && STREAMING_METHODS.has(method)) {
550
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, 'streaming is disabled on this agent', { method }));
551
+ return;
552
+ }
553
+ if (method === 'GetTask') return respond(handleGetTask(req, res, id, envelope.params));
554
+ if (method === 'CancelTask') return respond(handleCancelTask(req, res, id, envelope.params));
555
+ if (method === 'SendStreamingMessage') return handleStreaming(req, res, id, envelope.params);
556
+ return respond(handleSendMessage(req, res, id, envelope.params));
557
+ }
558
+
559
+ /**
560
+ * Wrap one handler so an unexpected throw still produces a JSON-RPC error.
561
+ *
562
+ * Without this a skill bug would surface as the webserver's bare 400, which
563
+ * tells an A2A peer nothing and looks like a network fault.
564
+ *
565
+ * @param {Promise<void>} work - handler promise.
566
+ * @returns {Promise<void>} resolves when the handler finished or failed.
567
+ */
568
+ async function respond(work) {
569
+ try {
570
+ await work;
571
+ } catch (error) {
572
+ warn('unhandled error while answering an A2A call', error);
573
+ // The handler may already have written; a second write would throw.
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Unary `SendMessage`: run one skill to completion and return the task.
579
+ *
580
+ * @param {A2aRequest} req - `node:http` request.
581
+ * @param {A2aResponse} res - `node:http` response.
582
+ * @param {string|number|null} id - request id.
583
+ * @param {A2aParams} params - `{message}`.
584
+ * @returns {Promise<void>} resolves when the response is written.
585
+ */
586
+ async function handleSendMessage(req, res, id, params) {
587
+ const admitted = await admit({ res, params });
588
+ if (admitted.error !== undefined) {
589
+ writeJson(res, 200, rpcError(id, admitted.error.code, admitted.error.message, admitted.error.data));
590
+ return;
591
+ }
592
+ const { task, skill, skillId, text, message } = admitted;
593
+ try {
594
+ await Promise.race([
595
+ runSkill({ task, skill, skillId, text, message, signal: admitted.signal, res: null, id }),
596
+ admitted.timeout,
597
+ ]);
598
+ writeJson(res, 200, rpcResult(id, { task: toWireTask(tasks.get(task.id) ?? task) }));
599
+ } catch (error) {
600
+ const failure = classify(error);
601
+ tasks.update(task.id, { state: 'failed', note: failure.message });
602
+ tasks.flush();
603
+ writeJson(res, 200, rpcError(id, failure.code, failure.message, { ...failure.data, taskId: task.id }));
604
+ } finally {
605
+ admitted.cleanup();
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Streaming `SendStreamingMessage`: frame the same run as SSE.
611
+ *
612
+ * The four committed frame kinds are `submitted`, `working`, one `artifactUpdate`
613
+ * per artifact, and the terminal `statusUpdate` carrying `final: true`. A peer
614
+ * that only waits for `final` still works, and a peer that renders progress gets
615
+ * something real instead of a single blob at the end.
616
+ *
617
+ * @param {A2aRequest} req - `node:http` request.
618
+ * @param {A2aResponse} res - `node:http` response.
619
+ * @param {string|number|null} id - request id.
620
+ * @param {A2aParams} params - `{message}`.
621
+ * @returns {Promise<void>} resolves when the stream ended.
622
+ */
623
+ async function handleStreaming(req, res, id, params) {
624
+ const admitted = await admit({ res, params });
625
+ if (admitted.error !== undefined) {
626
+ // Nothing has been streamed yet, so this is still a clean HTTP error.
627
+ writeJson(res, 200, rpcError(id, admitted.error.code, admitted.error.message, admitted.error.data));
628
+ return;
629
+ }
630
+ const { task, skill, skillId, text, message } = admitted;
631
+ const contextId = task.contextId;
632
+ writeSseHead(res);
633
+ writeSse(res, rpcResult(id, { statusUpdate: statusUpdate({ taskId: task.id, contextId, state: 'submitted', at: task.createdAt }) }));
634
+ tasks.update(task.id, { state: 'working' });
635
+ writeSse(res, rpcResult(id, { statusUpdate: statusUpdate({ taskId: task.id, contextId, state: 'working' }) }));
636
+ const heartbeat = setInterval(() => {
637
+ writeComment(res, `keep-alive ${new Date().toISOString()}`);
638
+ }, HEARTBEAT_MS);
639
+ heartbeat.unref?.();
640
+ const stop = () => clearInterval(heartbeat);
641
+ res?.once?.('close', stop);
642
+ try {
643
+ const outcome = await Promise.race([
644
+ runSkill({ task, skill, skillId, text, message, signal: admitted.signal, res, id }),
645
+ admitted.timeout,
646
+ ]);
647
+ for (const artifact of outcome.artifacts) {
648
+ writeSse(
649
+ res,
650
+ rpcResult(id, {
651
+ artifactUpdate: artifactUpdate({ taskId: task.id, contextId, artifact, append: false, lastChunk: true }),
652
+ }),
653
+ );
654
+ }
655
+ const finalState = outcome.finalState ?? 'completed';
656
+ const settled = tasks.update(task.id, { state: finalState }) ?? task;
657
+ writeSse(
658
+ res,
659
+ rpcResult(id, {
660
+ statusUpdate: statusUpdate({
661
+ taskId: task.id,
662
+ contextId,
663
+ state: finalState,
664
+ at: settled.updatedAt,
665
+ final: true,
666
+ }),
667
+ }),
668
+ );
669
+ } catch (error) {
670
+ const failure = classify(error);
671
+ const settled = tasks.update(task.id, { state: 'failed', note: failure.message }) ?? task;
672
+ writeSse(
673
+ res,
674
+ rpcResult(id, {
675
+ statusUpdate: statusUpdate({
676
+ taskId: task.id,
677
+ contextId,
678
+ state: 'failed',
679
+ note: failure.message,
680
+ at: settled.updatedAt,
681
+ final: true,
682
+ }),
683
+ }),
684
+ );
685
+ } finally {
686
+ stop();
687
+ res?.off?.('close', stop);
688
+ tasks.flush();
689
+ admitted.cleanup();
690
+ if (res?.writableEnded !== true) res?.end?.();
691
+ }
692
+ }
693
+
694
+ /**
695
+ * `GetTask`: read one task by id.
696
+ *
697
+ * @param {A2aRequest} req - `node:http` request.
698
+ * @param {A2aResponse} res - `node:http` response.
699
+ * @param {string|number|null} id - request id.
700
+ * @param {A2aParams} params - `{id}` (also tolerates `taskId` and 0.3's `name`).
701
+ * @returns {Promise<void>} resolves when the response is written.
702
+ */
703
+ async function handleGetTask(req, res, id, params) {
704
+ const taskId = taskIdOf(params);
705
+ if (taskId === '') {
706
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'GetTask requires params.id'));
707
+ return;
708
+ }
709
+ const task = tasks.get(taskId);
710
+ if (task === undefined) {
711
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, `no task "${taskId}"`, { code: 'task_not_found', taskId }));
712
+ return;
713
+ }
714
+ writeJson(res, 200, rpcResult(id, { task: toWireTask(task) }));
715
+ }
716
+
717
+ /**
718
+ * `CancelTask`: cancel a task that has not settled.
719
+ *
720
+ * @param {A2aRequest} req - `node:http` request.
721
+ * @param {A2aResponse} res - `node:http` response.
722
+ * @param {string|number|null} id - request id.
723
+ * @param {A2aParams} params - `{id}`.
724
+ * @returns {Promise<void>} resolves when the response is written.
725
+ */
726
+ async function handleCancelTask(req, res, id, params) {
727
+ const taskId = taskIdOf(params);
728
+ if (taskId === '') {
729
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'CancelTask requires params.id'));
730
+ return;
731
+ }
732
+ const { task } = tasks.cancel(taskId);
733
+ if (task === undefined) {
734
+ writeJson(res, 200, rpcError(id, RPC_ERRORS.INVALID_PARAMS, `no task "${taskId}"`, { code: 'task_not_found', taskId }));
735
+ return;
736
+ }
737
+ tasks.flush();
738
+ writeJson(res, 200, rpcResult(id, { task: toWireTask(task) }));
739
+ }
740
+
741
+ /**
742
+ * Validate one `SendMessage` request and open a task for it.
743
+ *
744
+ * Task creation happens before the skill runs so that every path after this
745
+ * point — success, timeout, failure, cancellation — has a task id to attach
746
+ * the outcome to. A peer that disconnects mid-call can still `GetTask` the id
747
+ * it guessed from a submission frame.
748
+ *
749
+ * @param {A2aAdmitInput} input - admission inputs; the response carries the disconnect signal.
750
+ * @returns {Promise<A2aAdmission>} `{task, skill, text, signal, timeout, cleanup}` or `{error}`.
751
+ */
752
+ async function admit({ res, params }) {
753
+ const message = params?.message;
754
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) {
755
+ return { error: { code: RPC_ERRORS.INVALID_PARAMS, message: 'SendMessage requires params.message' } };
756
+ }
757
+ if (!Array.isArray(message.parts) || message.parts.length === 0) {
758
+ return { error: { code: RPC_ERRORS.INVALID_PARAMS, message: 'params.message.parts must be a non-empty array' } };
759
+ }
760
+ const skillId = skillIdOf(message);
761
+ const skill = typeof skills?.get === 'function' ? skills.get(skillId) : undefined;
762
+ if (skill === undefined || skill === null) {
763
+ const known = typeof skills?.list === 'function' ? skills.list().map((entry) => entry?.id) : [];
764
+ return {
765
+ error: {
766
+ code: RPC_ERRORS.INVALID_PARAMS,
767
+ message: `unknown skill "${skillId}"`,
768
+ data: { code: 'unknown_skill', skill: skillId, known },
769
+ },
770
+ };
771
+ }
772
+ const text = textOfMessage(message);
773
+ const contextId = typeof message.contextId === 'string' && message.contextId !== '' ? message.contextId : undefined;
774
+ const task = tasks.create({ contextId, metadata: message.metadata });
775
+ return { task, skill, skillId, text, message, ...openCancellation({ res, taskId: task.id, skillId }) };
776
+ }
777
+
778
+ /**
779
+ * Arm the two ways a skill run can be cut short: a timeout and a disconnect.
780
+ *
781
+ * The timeout is enforced with an independent rejection rather than relying on
782
+ * the skill to honour its `AbortSignal` — a skill stuck in synchronous work or
783
+ * awaiting a promise it cannot cancel would otherwise hold the task open past
784
+ * its own deadline forever. The abort still fires so a cooperative skill can
785
+ * wind down, but the race is what guarantees the caller is answered.
786
+ *
787
+ * Disconnect detection watches the *response*, not the request. `req.close`
788
+ * also fires on a normal, fully-read request, so keying off it would abort
789
+ * every successful call; `res.close` fires when the connection goes away, and
790
+ * `res.writableEnded` tells us whether we had already answered.
791
+ *
792
+ * @param {object} input - cancellation inputs.
793
+ * @param {A2aResponse} input.res - the response, for the disconnect signal.
794
+ * @param {string} input.taskId - task being run.
795
+ * @param {string} input.skillId - skill being run.
796
+ * @returns {{signal: AbortSignal, timeout: Promise<any>, cleanup: () => void}} cancellation handles.
797
+ */
798
+ function openCancellation({ res, taskId, skillId }) {
799
+ const controller = new AbortController();
800
+ let timer;
801
+ const timeout = new Promise((_resolve, reject) => {
802
+ timer = setTimeout(() => {
803
+ const error = new Error(`skill "${skillId}" exceeded the ${taskTimeoutMs} ms task timeout`);
804
+ error.code = 'task_timeout';
805
+ controller.abort(error);
806
+ reject(error);
807
+ }, taskTimeoutMs);
808
+ timer.unref?.();
809
+ });
810
+ // A rejection nobody raced against must not become an unhandled rejection.
811
+ timeout.catch(() => {});
812
+ const onClose = () => {
813
+ if (res?.writableEnded === true) return;
814
+ controller.abort(new Error(`the A2A caller disconnected while task ${taskId} was running`));
815
+ };
816
+ res?.once?.('close', onClose);
817
+ const cleanup = () => {
818
+ clearTimeout(timer);
819
+ res?.off?.('close', onClose);
820
+ };
821
+ return { signal: controller.signal, timeout, cleanup };
822
+ }
823
+
824
+ /**
825
+ * Run one skill and normalize its result into wire artifacts.
826
+ *
827
+ * A skill may return a bare string (the common case), `{text, artifacts}`, or
828
+ * `{finalState}`. Normalizing here keeps every skill free of wire vocabulary
829
+ * and guarantees that every successful run produces at least one artifact —
830
+ * a completed task with no artifacts looks like a bug to a peer.
831
+ *
832
+ * The state transition is recorded on the task *before* the caller serializes
833
+ * it, so the `task` a unary caller receives and the `statusUpdate` a streaming
834
+ * caller receives are the same facts read from one place.
835
+ *
836
+ * `context.emit` is the streaming half of the skill contract. It is offered
837
+ * only when a stream exists; a skill that needs it (a subscription) throws
838
+ * `not_streaming` otherwise, which is truthful — a unary call has nowhere to
839
+ * put an unsolicited frame. `untilCancelled` is what lets a long-lived skill
840
+ * hold its stream open without owning the timeout policy.
841
+ *
842
+ * @param {object} input - run inputs.
843
+ * @param {object} input.task - task row.
844
+ * @param {A2aSkill} input.skill - registry skill.
845
+ * @param {string} input.skillId - resolved skill id.
846
+ * @param {string} input.text - request text.
847
+ * @param {object} input.message - the original 1.0 message.
848
+ * @param {AbortSignal} input.signal - cancellation and timeout.
849
+ * @param {object|null} input.res - response, for skills that stream themselves.
850
+ * @param {string|number|null} [input.id] - JSON-RPC id, echoed on each emitted frame.
851
+ * @returns {Promise<{artifacts: object[], finalState: string}>} the normalized outcome.
852
+ * @throws {Error} when the skill throws, aborts, or times out.
853
+ */
854
+ async function runSkill({ task, skill, skillId, text, message, signal, res, id }) {
855
+ const streaming = res !== null && res !== undefined && typeof res.write === 'function';
856
+ const context = {
857
+ taskId: task.id,
858
+ contextId: task.contextId,
859
+ messageId: typeof message?.messageId === 'string' ? message.messageId : undefined,
860
+ skill: skillId,
861
+ signal,
862
+ deliverer,
863
+ tasks,
864
+ logger,
865
+ now: () => Date.now(),
866
+ server: { card, info },
867
+ res,
868
+ request: { text, skill: skillId, message },
869
+ /**
870
+ * Stream one skill-defined event as an artifact frame.
871
+ *
872
+ * The artifact name carries the skill's own event name so a subscriber can
873
+ * tell a `post` from anything else a future skill emits; the payload is a
874
+ * single JSON text part, which is what keeps the wire shape stable no
875
+ * matter what the skill puts inside.
876
+ *
877
+ * @param {string} name - skill-defined event name, e.g. `post`.
878
+ * @param {unknown} body - event payload.
879
+ * @returns {void}
880
+ */
881
+ emit: streaming
882
+ ? (name, body) => {
883
+ const artifact = makeArtifact({
884
+ name: typeof name === 'string' && name !== '' ? name : 'event',
885
+ text: typeof body === 'string' ? body : JSON.stringify(body ?? null),
886
+ });
887
+ writeSse(
888
+ res,
889
+ rpcResult(id ?? null, {
890
+ artifactUpdate: artifactUpdate({ taskId: task.id, contextId: task.contextId, artifact, append: false, lastChunk: true }),
891
+ }),
892
+ );
893
+ }
894
+ : undefined,
895
+ /**
896
+ * Resolve when the caller goes away or the task deadline passes.
897
+ *
898
+ * Rejection is the honest outcome: a cancelled subscription was not
899
+ * "finished", and swallowing the abort would make the skill report a
900
+ * normal completion for work that never completed.
901
+ *
902
+ * @returns {Promise<never>} never resolves; rejects with the abort reason.
903
+ */
904
+ untilCancelled: () =>
905
+ new Promise((_resolve, reject) => {
906
+ if (signal === undefined || signal === null) {
907
+ reject(new Error('this skill requires a cancellable stream'));
908
+ return;
909
+ }
910
+ if (signal.aborted) {
911
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
912
+ return;
913
+ }
914
+ signal.addEventListener(
915
+ 'abort',
916
+ () => reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')),
917
+ { once: true },
918
+ );
919
+ }),
920
+ };
921
+ let result;
922
+ try {
923
+ // `data` is the caller's structured payload, unwrapped from the metadata
924
+ // envelope: the wire carries `metadata: {skill, data}`, and a skill that
925
+ // had to reach through `.data.data` would be inventing a second shape for
926
+ // the same idea. `metadata` is passed alongside for a skill that genuinely
927
+ // needs the whole envelope.
928
+ result = await skill.run(
929
+ {
930
+ text,
931
+ message,
932
+ data: isPlainObject(task.metadata?.data) ? task.metadata.data : {},
933
+ metadata: task.metadata,
934
+ skill: skillId,
935
+ messageId: message?.messageId,
936
+ },
937
+ context,
938
+ );
939
+ } catch (error) {
940
+ // A timeout or disconnect aborts the signal; report that reason rather
941
+ // than whatever the skill happened to throw while unwinding.
942
+ if (signal?.aborted === true && signal.reason instanceof Error) throw signal.reason;
943
+ throw error;
944
+ }
945
+ // A skill that finished after its deadline must not overwrite the failure
946
+ // the caller was already told about.
947
+ if (signal?.aborted === true) throw signal.reason instanceof Error ? signal.reason : new Error('aborted');
948
+ const artifacts = normalizeArtifacts(result);
949
+ for (const artifact of artifacts) tasks.attachArtifact(task.id, artifact);
950
+ // A skill's structured result rides on the task's own metadata, under a key of
951
+ // its own. It has to travel: `group/join` returns a member credential and
952
+ // `group/read` returns entries, and neither belongs in a text artifact a model
953
+ // would then have to parse back out. Mutated on the live task rather than
954
+ // through `update()`, which owns state transitions and nothing else.
955
+ if (isPlainObject(result?.meta)) {
956
+ const live = tasks.get(task.id);
957
+ if (live !== undefined) live.metadata = { ...(live.metadata ?? {}), result: result.meta };
958
+ }
959
+ const finalState = typeof result?.finalState === 'string' && result.finalState !== '' ? result.finalState : 'completed';
960
+ tasks.update(task.id, { state: finalState });
961
+ return { artifacts, finalState };
962
+ }
963
+
964
+ /**
965
+ * Decide whether a request may call this endpoint.
966
+ *
967
+ * With a token configured, the token is the only gate. Without one, loopback is
968
+ * the gate: an endpoint that silently accepts the whole LAN because an operator
969
+ * left a field blank is the classic way this class of plugin becomes an
970
+ * incident. The refusal names the reason (missing vs wrong credential) but
971
+ * never echoes what was presented.
972
+ *
973
+ * @param {A2aRequest} req - `node:http` request.
974
+ * @returns {{ok: true} | {ok: false, reason: string}} the verdict.
975
+ */
976
+ function authenticate(req) {
977
+ if (token === '') {
978
+ if (isLoopback(req?.socket?.remoteAddress)) return { ok: true };
979
+ return { ok: false, reason: 'this agent is bound to loopback; set config.server.token to accept remote callers' };
980
+ }
981
+ const presented = presentedToken(req);
982
+ if (presented === '') return { ok: false, reason: 'missing x-api-key or Authorization: Bearer credential' };
983
+ if (!safeEqual(presented, token)) return { ok: false, reason: 'invalid credential' };
984
+ return { ok: true };
985
+ }
986
+
987
+ /** Origin provider that tolerates a webserver whose listener has not bound. */
988
+ function originProvider() {
989
+ const webServer = ctx?.webServer;
990
+ if (webServer === null || typeof webServer !== 'object') return undefined;
991
+ if (typeof webServer.getOrigin !== 'function') return undefined;
992
+ return () => webServer.getOrigin();
993
+ }
994
+
995
+ /** Log a warning, scrubbing credentials out of any error detail. */
996
+ function warn(message, error) {
997
+ if (typeof logger?.warn !== 'function') return;
998
+ const detail = error === undefined ? '' : `: ${scrub(errorMessage(error))}`;
999
+ logger.warn(`dsh-a2a: ${scrub(message)}${detail}`);
1000
+ }
1001
+
1002
+ return { attach, detach, card, info };
1003
+ }
1004
+
1005
+
1006
+ /**
1007
+ * Read the request body up to a hard byte ceiling.
1008
+ *
1009
+ * The ceiling is enforced while reading, not from `Content-Length`: a peer can
1010
+ * lie about the header, and buffering whatever it sends is the whole attack this
1011
+ * check exists to stop.
1012
+ *
1013
+ * @param {A2aRequest} req - `node:http` request.
1014
+ * @param {number} limit - maximum bytes.
1015
+ * @returns {Promise<string>} the decoded body.
1016
+ * @throws {Error} with `code: 'body_too_large'` when the ceiling is exceeded.
1017
+ */
1018
+ async function readBody(req, limit) {
1019
+ if (req === null || typeof req !== 'object') return '';
1020
+ const declared = Number(req.headers?.['content-length']);
1021
+ if (Number.isFinite(declared) && declared > limit) {
1022
+ const error = new Error('body too large');
1023
+ error.code = 'body_too_large';
1024
+ throw error;
1025
+ }
1026
+ const chunks = [];
1027
+ let total = 0;
1028
+ for await (const chunk of /** @type {AsyncIterable<Buffer|string>} */ (req)) {
1029
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1030
+ total += buffer.length;
1031
+ if (total > limit) {
1032
+ const error = new Error('body too large');
1033
+ error.code = 'body_too_large';
1034
+ throw error;
1035
+ }
1036
+ chunks.push(buffer);
1037
+ }
1038
+ return Buffer.concat(chunks).toString('utf8');
1039
+ }
1040
+
1041
+ /**
1042
+ * Write a JSON response, guarding against a double write when the peer left.
1043
+ * @param {A2aResponse} res - `node:http` response.
1044
+ * @param {number} status - HTTP status.
1045
+ * @param {object} payload - JSON body.
1046
+ * @param {object} [headers] - extra headers.
1047
+ * @returns {void}
1048
+ */
1049
+ function writeJson(res, status, payload, headers) {
1050
+ if (res === null || typeof res !== 'object') return;
1051
+ if (res.writableEnded === true || res.destroyed === true) return;
1052
+ try {
1053
+ if (res.headersSent !== true) {
1054
+ res.statusCode = status;
1055
+ res.setHeader?.('Content-Type', 'application/json; charset=utf-8');
1056
+ res.setHeader?.('Cache-Control', 'no-store');
1057
+ if (headers !== null && typeof headers === 'object') {
1058
+ for (const [key, value] of Object.entries(headers)) res.setHeader?.(key, value);
1059
+ }
1060
+ }
1061
+ res.end(JSON.stringify(payload));
1062
+ } catch {
1063
+ // The socket is gone; nothing left to report to.
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Extract the presented credential from either accepted header.
1069
+ *
1070
+ * `Authorization: Bearer` wins when both are present because it is the standard
1071
+ * one; a caller that sets both is usually a proxy adding a header.
1072
+ *
1073
+ * @param {A2aRequest} req - `node:http` request.
1074
+ * @returns {string} the credential, or `''`.
1075
+ */
1076
+ function presentedToken(req) {
1077
+ const headers = req?.headers;
1078
+ if (headers === null || typeof headers !== 'object') return '';
1079
+ const apiKey = firstHeader(headers['x-api-key']);
1080
+ if (apiKey !== '') return apiKey;
1081
+ const authorization = firstHeader(headers.authorization);
1082
+ if (authorization === '') return '';
1083
+ const match = /^Bearer\s+(.+)$/i.exec(authorization);
1084
+ return match === null ? '' : match[1].trim();
1085
+ }
1086
+
1087
+ /**
1088
+ * Read one header as a trimmed string, tolerating the array form Node uses for
1089
+ * repeated headers.
1090
+ * @param {string|string[]|undefined} value - header value.
1091
+ * @returns {string} the value, or `''`.
1092
+ */
1093
+ function firstHeader(value) {
1094
+ if (Array.isArray(value)) {
1095
+ for (const entry of value) {
1096
+ if (typeof entry === 'string' && entry.trim() !== '') return entry.trim();
1097
+ }
1098
+ return '';
1099
+ }
1100
+ return typeof value === 'string' ? value.trim() : '';
1101
+ }
1102
+
1103
+ /**
1104
+ * Read the `A2A-Version` header, defaulting to the implied legacy version.
1105
+ * @param {A2aRequest} req - `node:http` request.
1106
+ * @returns {string} the version the caller speaks.
1107
+ */
1108
+ function versionOf(req) {
1109
+ const value = firstHeader(req?.headers?.[VERSION_HEADER]);
1110
+ return value === '' ? IMPLIED_LEGACY_VERSION : value;
1111
+ }
1112
+
1113
+ /**
1114
+ * Extract the skill id from a message's metadata.
1115
+ * @param {object} message - A2A message.
1116
+ * @returns {string} the skill id, defaulting to `session/ask`.
1117
+ */
1118
+ function skillIdOf(message) {
1119
+ const candidate = message?.metadata?.skill;
1120
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
1121
+ return DEFAULT_SKILL;
1122
+ }
1123
+
1124
+ /**
1125
+ * Extract a task id from either the 1.0 (`id`) or legacy (`taskId`) spelling.
1126
+ * @param {A2aParams} params - JSON-RPC params.
1127
+ * @returns {string} the task id, or `''`.
1128
+ */
1129
+ function taskIdOf(params) {
1130
+ for (const key of ['id', 'taskId', 'name']) {
1131
+ const value = params?.[key];
1132
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
1133
+ }
1134
+ return '';
1135
+ }
1136
+
1137
+ /** Whether a method may be answered from cache semantics (read-only). */
1138
+ function isReadMethod(method) {
1139
+ return method === 'GET' || method === 'HEAD';
1140
+ }
1141
+
1142
+ /** Whether a method may carry a body. */
1143
+ function isWriteMethod(method) {
1144
+ return method === 'POST';
1145
+ }
1146
+
1147
+ /**
1148
+ * Whether an address is loopback, including the IPv4-mapped IPv6 forms Node
1149
+ * reports for a `127.0.0.1` connection on a dual-stack listener.
1150
+ * @param {string|undefined} address - `socket.remoteAddress`.
1151
+ * @returns {boolean} true when the peer is this machine.
1152
+ */
1153
+ function isLoopback(address) {
1154
+ if (typeof address !== 'string' || address === '') return false;
1155
+ const value = address.trim().toLowerCase();
1156
+ if (value === '::1' || value === '::ffff:127.0.0.1' || value === '0:0:0:0:0:0:0:1') return true;
1157
+ const match = /^(?:\.)?(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
1158
+ if (match !== null) return Number(match[1]) === 127;
1159
+ return false;
1160
+ }
1161
+
1162
+ /** Read the request pathname without a URL parse (the Host may have rewritten it). */
1163
+ function pathOf(req) {
1164
+ const raw = typeof req?.url === 'string' ? req.url : '';
1165
+ const cut = raw.split('?')[0];
1166
+ return cut === '' ? '/' : cut;
1167
+ }
1168
+
1169
+ /**
1170
+ * Classify a thrown value into a JSON-RPC error.
1171
+ *
1172
+ * A skill that wants a specific A2A code raises an error with a string `code`
1173
+ * from the contract's table; everything else is an internal failure. Returning
1174
+ * `-32004` with `data.code` is what lets a caller distinguish "you are over your
1175
+ * post budget" from "this plugin crashed", without parsing prose.
1176
+ *
1177
+ * @param {unknown} error - the thrown value.
1178
+ * @returns {{code: number, message: string, data: object}} the JSON-RPC error fields.
1179
+ */
1180
+ function classify(error) {
1181
+ const message = errorMessage(error);
1182
+ const raw = typeof error?.code === 'string' ? error.code : '';
1183
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
1184
+ return { code: RPC_ERRORS.INTERNAL, message, data: { code: 'aborted' } };
1185
+ }
1186
+ // A deadline or a disconnect is this Host's failure, not a business refusal;
1187
+ // the stable sub-code still tells the peer it may retry.
1188
+ if (raw === 'task_timeout' || raw === 'aborted') {
1189
+ return { code: RPC_ERRORS.INTERNAL, message, data: { code: raw } };
1190
+ }
1191
+ if (raw !== '') {
1192
+ return { code: RPC_ERRORS.REJECTED, message, data: { code: raw } };
1193
+ }
1194
+ if (typeof error?.status === 'number' && error.status === 401) {
1195
+ return { code: RPC_ERRORS.UNAUTHENTICATED, message, data: { code: 'unauthenticated' } };
1196
+ }
1197
+ return { code: RPC_ERRORS.INTERNAL, message: message === '' ? 'skill execution failed' : message, data: { code: 'internal' } };
1198
+ }
1199
+
1200
+ /** Render a thrown value as a message string. */
1201
+ function errorMessage(error) {
1202
+ if (error instanceof Error) return error.message;
1203
+ if (typeof error === 'string') return error;
1204
+ if (error === null || error === undefined) return '';
1205
+ try {
1206
+ return JSON.stringify(error);
1207
+ } catch {
1208
+ return String(error);
1209
+ }
1210
+ }
1211
+
1212
+ /**
1213
+ * Normalize whatever a skill returned into wire artifact values.
1214
+ *
1215
+ * Skills are ordinary code, often written by another branch of this project, so
1216
+ * the accepted shapes are generous: a bare string, `{text}`, `{artifacts: [...]}`
1217
+ * with either pre-built parts or a `text` field. The one rule enforced here is
1218
+ * that a successful run yields at least one artifact — an empty `artifacts` array
1219
+ * on a completed task reads as a silent failure to every A2A client.
1220
+ *
1221
+ * @param {unknown} result - value returned by `skill.run()`.
1222
+ * @returns {object[]} zero or more artifact values.
1223
+ */
1224
+ function normalizeArtifacts(result) {
1225
+ const artifacts = [];
1226
+ if (typeof result === 'string') {
1227
+ if (result !== '') artifacts.push(makeArtifact({ name: 'reply', text: result }));
1228
+ return artifacts;
1229
+ }
1230
+ if (result === null || typeof result !== 'object') return artifacts;
1231
+ const declared = Array.isArray(result.artifacts) ? result.artifacts : [];
1232
+ for (const entry of declared) {
1233
+ if (entry === null || typeof entry !== 'object') continue;
1234
+ if (typeof entry.artifactId === 'string' && entry.artifactId !== '' && Array.isArray(entry.parts)) {
1235
+ artifacts.push(entry);
1236
+ continue;
1237
+ }
1238
+ artifacts.push(
1239
+ makeArtifact({
1240
+ artifactId: typeof entry.artifactId === 'string' ? entry.artifactId : undefined,
1241
+ name: typeof entry.name === 'string' && entry.name !== '' ? entry.name : 'reply',
1242
+ parts: Array.isArray(entry.parts) ? entry.parts : undefined,
1243
+ text: typeof entry.text === 'string' ? entry.text : '',
1244
+ }),
1245
+ );
1246
+ }
1247
+ if (artifacts.length === 0 && typeof result.text === 'string' && result.text !== '') {
1248
+ artifacts.push(makeArtifact({ name: typeof result.name === 'string' && result.name !== '' ? result.name : 'reply', text: result.text }));
1249
+ }
1250
+ return artifacts;
1251
+ }
1252
+
1253
+ /**
1254
+ * Mask credential-looking `key=value` or `key: value` pairs in a log line.
1255
+ *
1256
+ * Server-side logging is the last place a token can escape: an upstream error
1257
+ * message, a skill's own error, or a misconfigured card all flow through here.
1258
+ * @param {string} text - candidate log text.
1259
+ * @returns {string} text with secrets replaced by a short digest.
1260
+ */
1261
+ function scrub(text) {
1262
+ return redactAll(String(text ?? ''), [process.env?.A2A_LOG_SECRET]);
1263
+ }
1264
+
1265
+ /** Coerce a config number into a positive integer, falling back on nonsense. */
1266
+ function positiveInt(value, fallback) {
1267
+ if (!Number.isFinite(value) || value <= 0) return fallback;
1268
+ return Math.floor(value);
1269
+ }
1270
+
1271
+ /**
1272
+ * Whether a value is a plain JSON object (not an array, not null).
1273
+ *
1274
+ * Used to decide whether a caller's `metadata.data` is structured input or
1275
+ * absent: an array or a primitive there is a client mistake, and passing it
1276
+ * through as `data` would make every skill re-check the same thing.
1277
+ *
1278
+ * @param {unknown} value
1279
+ * @returns {boolean}
1280
+ */
1281
+ function isPlainObject(value) {
1282
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1283
+ }
1284
+
1285
+ /**
1286
+ * Read the layer B profile, tolerating a provider that fails.
1287
+ *
1288
+ * A card is how a peer discovers what this agent can do; losing the whole card
1289
+ * because the identity aggregation hit a corrupt file would be the wrong trade.
1290
+ * A failed read therefore degrades to "no profile", which the card renders as its
1291
+ * default name and description.
1292
+ *
1293
+ * @param {(() => object)|undefined} provider - returns the profile, or undefined.
1294
+ * @returns {object|undefined} the profile, or undefined.
1295
+ */
1296
+ function readProfile(provider) {
1297
+ if (typeof provider !== 'function') return undefined;
1298
+ try {
1299
+ const value = provider();
1300
+ return value === null || value === undefined ? undefined : value;
1301
+ } catch {
1302
+ return undefined;
1303
+ }
1304
+ }