@axiom-lattice/gateway 2.1.89 → 2.1.90

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/jest.config.js CHANGED
@@ -64,7 +64,7 @@ module.exports = {
64
64
  "^e2b$": "<rootDir>/src/__tests__/__mocks__/e2b.ts",
65
65
  // Strip .js extension from ESM-style imports (must come after all @-scoped mappings)
66
66
  "^(@.*)[.]js$": "$1",
67
- "^(\..*)[.]js$": "$1",
67
+ "^(\\.{1,2}/.*)\\.js$": "$1",
68
68
  },
69
69
  collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/__tests__/**/*"],
70
70
  coverageReporters: ["text", "lcov", "html"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/gateway",
3
- "version": "2.1.89",
3
+ "version": "2.1.90",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -40,11 +40,11 @@
40
40
  "redis": "^5.0.1",
41
41
  "uuid": "^9.0.1",
42
42
  "zod": "3.25.76",
43
- "@axiom-lattice/agent-eval": "2.1.71",
44
- "@axiom-lattice/core": "2.1.77",
45
- "@axiom-lattice/pg-stores": "1.0.68",
46
- "@axiom-lattice/protocols": "2.1.39",
47
- "@axiom-lattice/queue-redis": "1.0.38"
43
+ "@axiom-lattice/agent-eval": "2.1.72",
44
+ "@axiom-lattice/core": "2.1.78",
45
+ "@axiom-lattice/pg-stores": "1.0.69",
46
+ "@axiom-lattice/protocols": "2.1.40",
47
+ "@axiom-lattice/queue-redis": "1.0.39"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/jest": "^29.5.14",
@@ -0,0 +1,346 @@
1
+ import { FastifyInstance } from "fastify";
2
+ import fastify from "fastify";
3
+ import { registerA2ARoutes } from "../routes/a2a";
4
+
5
+ const mockChunkStream = jest.fn();
6
+ const mockAddMessage = jest.fn();
7
+ const mockAbort = jest.fn();
8
+
9
+ const mockAgent = {
10
+ addMessage: mockAddMessage,
11
+ chunkStream: mockChunkStream,
12
+ abort: mockAbort,
13
+ };
14
+
15
+ const mockGetAgent = jest.fn(() => mockAgent);
16
+
17
+ jest.mock("@axiom-lattice/core", () => ({
18
+ agentInstanceManager: {
19
+ getAgent: (...args: unknown[]) => mockGetAgent(...args),
20
+ },
21
+ }));
22
+
23
+ describe("A2A Routes", () => {
24
+ let app: FastifyInstance;
25
+
26
+ beforeEach(() => {
27
+ mockGetAgent.mockClear();
28
+ mockAddMessage.mockClear();
29
+ mockChunkStream.mockClear();
30
+ mockAbort.mockClear();
31
+ mockGetAgent.mockReturnValue(mockAgent);
32
+
33
+ process.env.A2A_API_KEYS = "valid-key:tenant-a";
34
+ process.env.A2A_DEFAULT_AGENT_ID = "agent-1";
35
+ process.env.A2A_DEFAULT_TENANT_ID = "default-tenant";
36
+ delete process.env.A2A_DEFAULT_PROJECT_ID;
37
+ delete process.env.A2A_DEFAULT_WORKSPACE_ID;
38
+
39
+ app = fastify();
40
+ registerA2ARoutes(app);
41
+ });
42
+
43
+ afterEach(async () => {
44
+ await app.close();
45
+ delete process.env.A2A_API_KEYS;
46
+ delete process.env.A2A_DEFAULT_AGENT_ID;
47
+ delete process.env.A2A_DEFAULT_TENANT_ID;
48
+ });
49
+
50
+ // ─── Agent Card ──────────────────────────────────────────────────────
51
+
52
+ describe("GET /api/a2a/.well-known/agent.json", () => {
53
+ it("should return agent card without auth", async () => {
54
+ const response = await app.inject({
55
+ method: "GET",
56
+ url: "/api/a2a/.well-known/agent.json",
57
+ });
58
+
59
+ expect(response.statusCode).toBe(200);
60
+ const body = JSON.parse(response.body);
61
+ expect(body).toHaveProperty("name", "Axiom Lattice Agent");
62
+ expect(body).toHaveProperty("capabilities");
63
+ expect(body.capabilities).toHaveProperty("streaming", true);
64
+ expect(body).toHaveProperty("defaultInputModes");
65
+ expect(body).toHaveProperty("defaultOutputModes");
66
+ expect(body).toHaveProperty("url");
67
+ });
68
+ });
69
+
70
+ describe("GET /api/a2a/.well-known/agent-card.json", () => {
71
+ it("should support alternate path", async () => {
72
+ const response = await app.inject({
73
+ method: "GET",
74
+ url: "/api/a2a/.well-known/agent-card.json",
75
+ });
76
+
77
+ expect(response.statusCode).toBe(200);
78
+ const body = JSON.parse(response.body);
79
+ expect(body).toHaveProperty("name", "Axiom Lattice Agent");
80
+ });
81
+ });
82
+
83
+ // ─── Agent Card with env override ────────────────────────────────────
84
+
85
+ describe("Agent Card with custom env", () => {
86
+ it("should reflect custom agent name from env", async () => {
87
+ process.env.A2A_AGENT_NAME = "Custom Bot";
88
+ process.env.A2A_ORGANIZATION = "Custom Org";
89
+ process.env.A2A_VERSION = "2.0.0";
90
+
91
+ const response = await app.inject({
92
+ method: "GET",
93
+ url: "/api/a2a/.well-known/agent.json",
94
+ });
95
+
96
+ expect(response.statusCode).toBe(200);
97
+ const body = JSON.parse(response.body);
98
+ expect(body.name).toBe("Custom Bot");
99
+ expect(body.provider.organization).toBe("Custom Org");
100
+ expect(body.version).toBe("2.0.0");
101
+
102
+ delete process.env.A2A_AGENT_NAME;
103
+ delete process.env.A2A_ORGANIZATION;
104
+ delete process.env.A2A_VERSION;
105
+ });
106
+ });
107
+
108
+ // ─── Task Send ───────────────────────────────────────────────────────
109
+
110
+ describe("POST /api/a2a/tasks/send", () => {
111
+ it("should return 400 when message.parts is missing", async () => {
112
+ const response = await app.inject({
113
+ method: "POST",
114
+ url: "/api/a2a/tasks/send",
115
+ headers: {
116
+ authorization: "Bearer valid-key",
117
+ "content-type": "application/json",
118
+ },
119
+ payload: { message: {} },
120
+ });
121
+
122
+ expect(response.statusCode).toBe(400);
123
+ const body = JSON.parse(response.body);
124
+ expect(body.error).toBe("message.parts is required");
125
+ });
126
+
127
+ it("should return 400 when message text is empty", async () => {
128
+ const response = await app.inject({
129
+ method: "POST",
130
+ url: "/api/a2a/tasks/send",
131
+ headers: {
132
+ authorization: "Bearer valid-key",
133
+ "content-type": "application/json",
134
+ },
135
+ payload: {
136
+ message: {
137
+ role: "user",
138
+ parts: [{ type: "text", text: "" }],
139
+ },
140
+ },
141
+ });
142
+
143
+ expect(response.statusCode).toBe(400);
144
+ const body = JSON.parse(response.body);
145
+ expect(body.error).toBe("message must contain text content");
146
+ });
147
+
148
+ it("should return 500 when no agent is configured", async () => {
149
+ delete process.env.A2A_DEFAULT_AGENT_ID;
150
+
151
+ const response = await app.inject({
152
+ method: "POST",
153
+ url: "/api/a2a/tasks/send",
154
+ headers: {
155
+ authorization: "Bearer valid-key",
156
+ "content-type": "application/json",
157
+ },
158
+ payload: {
159
+ message: {
160
+ role: "user",
161
+ parts: [{ type: "text", text: "Hello" }],
162
+ },
163
+ },
164
+ });
165
+
166
+ expect(response.statusCode).toBe(500);
167
+ const body = JSON.parse(response.body);
168
+ expect(body.error).toBe("No agent configured");
169
+ });
170
+
171
+ it("should return 401 with invalid API key", async () => {
172
+ const response = await app.inject({
173
+ method: "POST",
174
+ url: "/api/a2a/tasks/send",
175
+ headers: {
176
+ authorization: "Bearer wrong-key",
177
+ "content-type": "application/json",
178
+ },
179
+ payload: {
180
+ message: {
181
+ role: "user",
182
+ parts: [{ type: "text", text: "Hello" }],
183
+ },
184
+ },
185
+ });
186
+
187
+ expect(response.statusCode).toBe(401);
188
+ });
189
+
190
+ it("should stream task result with valid key and message", async () => {
191
+ mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
192
+
193
+ const mockStream = (async function* () {
194
+ yield {
195
+ type: "ai",
196
+ data: { id: "c1", content: "Hello " },
197
+ };
198
+ yield {
199
+ type: "ai",
200
+ data: { id: "c2", content: "World" },
201
+ };
202
+ })();
203
+ mockChunkStream.mockReturnValueOnce(mockStream);
204
+
205
+ const response = await app.inject({
206
+ method: "POST",
207
+ url: "/api/a2a/tasks/send",
208
+ headers: {
209
+ authorization: "Bearer valid-key",
210
+ "content-type": "application/json",
211
+ },
212
+ payload: {
213
+ message: {
214
+ role: "user",
215
+ parts: [{ type: "text", text: "Hello World" }],
216
+ },
217
+ },
218
+ });
219
+
220
+ expect(response.statusCode).toBe(200);
221
+ expect(response.headers["content-type"]).toBe("text/event-stream");
222
+
223
+ // Verify SSE events were emitted
224
+ const body = response.body;
225
+ expect(body).toContain("event: status-update");
226
+ expect(body).toContain('"state":"working"');
227
+ expect(body).toContain("Hello ");
228
+ expect(body).toContain("World");
229
+ expect(body).toContain('"state":"completed"');
230
+ expect(body).toContain('"final":true');
231
+
232
+ // Verify agent was called with correct scope from API key
233
+ expect(mockGetAgent).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ assistant_id: "agent-1",
236
+ tenant_id: "tenant-a",
237
+ }),
238
+ );
239
+
240
+ // Verify agent was called with workspace/project from API key
241
+ // tenant-a has no project/workspace, so they should be undefined
242
+ const getAgentCall = mockGetAgent.mock.calls[0][0];
243
+ expect(getAgentCall.project_id).toBeUndefined();
244
+ expect(getAgentCall.workspace_id).toBeUndefined();
245
+ });
246
+
247
+ it("should propagate project scope from API key", async () => {
248
+ process.env.A2A_API_KEYS = "scoped-key:tenant-a:proj-x:ws-y";
249
+
250
+ mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
251
+ const mockStream = (async function* () {
252
+ yield { type: "ai", data: { id: "c1", content: "ok" } };
253
+ })();
254
+ mockChunkStream.mockReturnValueOnce(mockStream);
255
+
256
+ const response = await app.inject({
257
+ method: "POST",
258
+ url: "/api/a2a/tasks/send",
259
+ headers: {
260
+ authorization: "Bearer scoped-key",
261
+ "content-type": "application/json",
262
+ },
263
+ payload: {
264
+ message: {
265
+ role: "user",
266
+ parts: [{ type: "text", text: "Hello" }],
267
+ },
268
+ },
269
+ });
270
+
271
+ expect(response.statusCode).toBe(200);
272
+
273
+ const getAgentCall = mockGetAgent.mock.calls[0][0];
274
+ expect(getAgentCall.tenant_id).toBe("tenant-a");
275
+ expect(getAgentCall.project_id).toBe("proj-x");
276
+ expect(getAgentCall.workspace_id).toBe("ws-y");
277
+ });
278
+ });
279
+
280
+ // ─── Task Cancel ─────────────────────────────────────────────────────
281
+
282
+ describe("POST /api/a2a/tasks/:taskId/cancel", () => {
283
+ it("should return 404 for unknown task", async () => {
284
+ const response = await app.inject({
285
+ method: "POST",
286
+ url: "/api/a2a/tasks/nonexistent/cancel",
287
+ headers: {
288
+ authorization: "Bearer valid-key",
289
+ "content-type": "application/json",
290
+ },
291
+ payload: {},
292
+ });
293
+
294
+ expect(response.statusCode).toBe(404);
295
+ });
296
+ });
297
+
298
+ // ─── Task Get ────────────────────────────────────────────────────────
299
+
300
+ describe("GET /api/a2a/tasks/:taskId", () => {
301
+ it("should return 404 for unknown task", async () => {
302
+ const response = await app.inject({
303
+ method: "GET",
304
+ url: "/api/a2a/tasks/nonexistent",
305
+ headers: {
306
+ authorization: "Bearer valid-key",
307
+ "content-type": "application/json",
308
+ },
309
+ });
310
+
311
+ expect(response.statusCode).toBe(404);
312
+ });
313
+ });
314
+
315
+ // ─── No auth mode ────────────────────────────────────────────────────
316
+
317
+ describe("when no API keys configured", () => {
318
+ beforeEach(() => {
319
+ delete process.env.A2A_API_KEYS;
320
+ });
321
+
322
+ it("should accept requests without auth", async () => {
323
+ mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
324
+ const mockStream = (async function* () {
325
+ yield { type: "ai", data: { id: "c1", content: "Hi" } };
326
+ })();
327
+ mockChunkStream.mockReturnValueOnce(mockStream);
328
+
329
+ const response = await app.inject({
330
+ method: "POST",
331
+ url: "/api/a2a/tasks/send",
332
+ headers: {
333
+ "content-type": "application/json",
334
+ },
335
+ payload: {
336
+ message: {
337
+ role: "user",
338
+ parts: [{ type: "text", text: "Hello" }],
339
+ },
340
+ },
341
+ });
342
+
343
+ expect(response.statusCode).toBe(200);
344
+ });
345
+ });
346
+ });
package/src/index.ts CHANGED
@@ -131,6 +131,14 @@ app.addHook("preHandler", async (request, reply) => {
131
131
  });
132
132
  });
133
133
 
134
+ // Add default header values for project/workspace isolation
135
+ app.addHook("onRequest", (request, reply, done) => {
136
+ if (!request.headers["x-project-id"]) {
137
+ request.headers["x-project-id"] = "default";
138
+ }
139
+ done();
140
+ });
141
+
134
142
  // Add custom logging hooks
135
143
  app.addHook("onRequest", (request, reply, done) => {
136
144
  const context = {
@@ -294,6 +302,17 @@ const start = async (config?: LatticeGatewayConfig) => {
294
302
  });
295
303
 
296
304
  channelDeps = { router, installationStore };
305
+
306
+ // Initialize A2A API key store
307
+ try {
308
+ const a2aKeyStore = getStoreLattice("default", "a2aApiKey").store as import("@axiom-lattice/protocols").A2AApiKeyStore;
309
+ const a2a = await import("./routes/a2a");
310
+ a2a.setA2AKeyStore(a2aKeyStore);
311
+ await a2a.refreshStoreKeyMap();
312
+ logger.info("A2A key store initialized");
313
+ } catch {
314
+ // A2A key store optional — env-based keys still work
315
+ }
297
316
  } catch {
298
317
  // Stores not registered — channel routes will be skipped gracefully
299
318
  }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * A2A Bridge — WebSocket-based reverse proxy for agents behind NAT.
3
+ *
4
+ * Agents open an outbound WebSocket to the gateway. The gateway stores
5
+ * the connection and exposes an HTTP proxy endpoint per agent. When the
6
+ * orchestrator calls the agent, the gateway proxies the HTTP request
7
+ * over the persistent WebSocket instead of trying to reach a private IP.
8
+ *
9
+ * Agent flow:
10
+ * 1. Connect ws://gateway/api/a2a/bridge
11
+ * 2. Send { type: "agent:register", agentId: "...", agentCard: {...} }
12
+ * 3. Wait for { type: "http:request", requestId, method, path, headers, body }
13
+ * 4. Process locally, respond with { type: "http:response", requestId, status, headers, body }
14
+ *
15
+ * Gateway flow:
16
+ * 1. Accept WebSocket, store connection by agentId
17
+ * 2. Expose GET/POST /api/a2a/bridge/proxy/{agentId}/*
18
+ * 3. When HTTP request arrives, push over WebSocket, await response, relay
19
+ */
20
+
21
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
22
+ import type { WebSocket } from 'ws';
23
+ import { v4 } from 'uuid';
24
+
25
+ // ─── Logging ────────────────────────────────────────────────────────────────
26
+
27
+ const log = {
28
+ info(msg: string, ctx?: Record<string, unknown>) {
29
+ console.log(JSON.stringify({ level: 'info', msg, ...ctx }));
30
+ },
31
+ warn(msg: string, ctx?: Record<string, unknown>) {
32
+ console.warn(JSON.stringify({ level: 'warn', msg, ...ctx }));
33
+ },
34
+ error(msg: string, ctx?: Record<string, unknown>) {
35
+ console.error(JSON.stringify({ level: 'error', msg, ...ctx }));
36
+ },
37
+ };
38
+
39
+ // ─── Types ──────────────────────────────────────────────────────────────────
40
+
41
+ interface PendingRequest {
42
+ resolve: (response: BridgeHttpResponse) => void;
43
+ reject: (error: Error) => void;
44
+ timer: ReturnType<typeof setTimeout>;
45
+ /** The agent owning this pending request — used for cleanup on disconnect */
46
+ agentId: string;
47
+ }
48
+
49
+ interface BridgeHttpResponse {
50
+ status: number;
51
+ headers: Record<string, string>;
52
+ body: string;
53
+ }
54
+
55
+ interface AgentConnection {
56
+ ws: WebSocket;
57
+ agentId: string;
58
+ agentCard: Record<string, unknown>;
59
+ connectedAt: number;
60
+ }
61
+
62
+ // ─── Connection Registry ────────────────────────────────────────────────────
63
+
64
+ const connections = new Map<string, AgentConnection>();
65
+ const pending = new Map<string, PendingRequest>();
66
+
67
+ function getAgentIdFromPath(path: string): string | null {
68
+ // Path: /api/a2a/bridge/proxy/{agentId}/...
69
+ const match = path.match(/^\/api\/a2a\/bridge\/proxy\/([^/]+)/);
70
+ return match ? match[1] : null;
71
+ }
72
+
73
+ // ─── WebSocket Handler ──────────────────────────────────────────────────────
74
+
75
+ function handleBridgeConnection(socket: WebSocket, _request: FastifyRequest): void {
76
+ let agentId: string | null = null;
77
+
78
+ log.info('Bridge WebSocket connected');
79
+
80
+ socket.on('message', (raw) => {
81
+ let msg: Record<string, unknown>;
82
+ try {
83
+ msg = JSON.parse(raw.toString());
84
+ } catch {
85
+ log.warn('Bridge invalid message');
86
+ return;
87
+ }
88
+
89
+ switch (msg.type) {
90
+ case 'agent:register': {
91
+ agentId = msg.agentId as string;
92
+ if (!agentId) {
93
+ socket.send(JSON.stringify({ type: 'error', message: 'agentId required' }));
94
+ socket.close();
95
+ return;
96
+ }
97
+ connections.set(agentId, {
98
+ ws: socket,
99
+ agentId,
100
+ agentCard: msg.agentCard as Record<string, unknown> ?? {},
101
+ connectedAt: Date.now(),
102
+ });
103
+ log.info('Agent registered via bridge', { agentId });
104
+ socket.send(JSON.stringify({ type: 'agent:registered', agentId }));
105
+ break;
106
+ }
107
+
108
+ case 'http:response': {
109
+ const requestId = msg.requestId as string;
110
+ const p = pending.get(requestId);
111
+ if (p) {
112
+ clearTimeout(p.timer);
113
+ pending.delete(requestId);
114
+ p.resolve({
115
+ status: (msg.status as number) ?? 200,
116
+ headers: (msg.headers as Record<string, string>) ?? {},
117
+ body: (msg.body as string) ?? '',
118
+ });
119
+ }
120
+ break;
121
+ }
122
+
123
+ case 'http:error': {
124
+ const requestId = msg.requestId as string;
125
+ const p = pending.get(requestId);
126
+ if (p) {
127
+ clearTimeout(p.timer);
128
+ pending.delete(requestId);
129
+ p.reject(new Error((msg.message as string) ?? 'Agent error'));
130
+ }
131
+ break;
132
+ }
133
+ }
134
+ });
135
+
136
+ socket.on('close', () => {
137
+ if (agentId) {
138
+ connections.delete(agentId);
139
+ log.info('Bridge WebSocket disconnected', { agentId });
140
+ // Reject only this agent's pending requests
141
+ for (const [reqId, p] of pending) {
142
+ if (p.agentId === agentId) {
143
+ clearTimeout(p.timer);
144
+ p.reject(new Error('Agent disconnected'));
145
+ pending.delete(reqId);
146
+ }
147
+ }
148
+ }
149
+ });
150
+
151
+ socket.on('error', (err) => {
152
+ log.error('Bridge WebSocket error', { agentId, error: err.message });
153
+ });
154
+ }
155
+
156
+ // ─── HTTP Proxy Handler ─────────────────────────────────────────────────────
157
+
158
+ async function handleBridgeProxy(
159
+ request: FastifyRequest,
160
+ reply: FastifyReply,
161
+ ): Promise<void> {
162
+ const agentId = getAgentIdFromPath(request.url);
163
+ if (!agentId) {
164
+ reply.status(400).send({ error: 'Agent ID not found in path' });
165
+ return;
166
+ }
167
+
168
+ const conn = connections.get(agentId);
169
+ if (!conn) {
170
+ reply.status(503).send({
171
+ error: 'Agent not connected',
172
+ message: `Agent "${agentId}" is not connected via WebSocket bridge`,
173
+ });
174
+ return;
175
+ }
176
+
177
+ const requestId = v4();
178
+
179
+ // Build sub-path (strip the proxy prefix)
180
+ const subPath = request.url.replace(`/api/a2a/bridge/proxy/${agentId}`, '') || '/';
181
+
182
+ // Read body
183
+ let body: string | undefined;
184
+ if (request.method === 'POST' || request.method === 'PUT') {
185
+ body = typeof request.body === 'string'
186
+ ? request.body
187
+ : JSON.stringify(request.body ?? {});
188
+ }
189
+
190
+ const bridgeRequest = {
191
+ type: 'http:request',
192
+ requestId,
193
+ method: request.method,
194
+ path: subPath,
195
+ headers: {
196
+ 'content-type': request.headers['content-type'] ?? 'application/json',
197
+ 'accept': request.headers['accept'] ?? '*/*',
198
+ },
199
+ body: body ?? '',
200
+ };
201
+
202
+ const timeout = 300_000; // 5 min
203
+
204
+ try {
205
+ const response = await new Promise<BridgeHttpResponse>((resolve, reject) => {
206
+ const timer = setTimeout(() => {
207
+ pending.delete(requestId);
208
+ reject(new Error(`Bridge request timeout after ${timeout}ms`));
209
+ }, timeout);
210
+
211
+ pending.set(requestId, { resolve, reject, timer, agentId });
212
+
213
+ try {
214
+ if (conn.ws.readyState !== conn.ws.OPEN) {
215
+ pending.delete(requestId);
216
+ reject(new Error('WebSocket not open'));
217
+ return;
218
+ }
219
+ conn.ws.send(JSON.stringify(bridgeRequest));
220
+ } catch (err) {
221
+ clearTimeout(timer);
222
+ pending.delete(requestId);
223
+ reject(err as Error);
224
+ }
225
+ });
226
+
227
+ // Relay response headers
228
+ for (const [key, value] of Object.entries(response.headers)) {
229
+ if (!['content-length', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
230
+ reply.header(key, value);
231
+ }
232
+ }
233
+
234
+ reply.status(response.status);
235
+
236
+ // Try to send as JSON if possible
237
+ try {
238
+ const parsed = JSON.parse(response.body);
239
+ reply.send(parsed);
240
+ } catch {
241
+ reply.send(response.body);
242
+ }
243
+ } catch (err) {
244
+ log.error('Bridge proxy error', { agentId, error: (err as Error).message });
245
+ reply.status(502).send({
246
+ error: 'Bridge proxy error',
247
+ message: (err as Error).message,
248
+ });
249
+ }
250
+ }
251
+
252
+ // ─── Route Registration ─────────────────────────────────────────────────────
253
+
254
+ export function registerA2ABridgeRoutes(app: FastifyInstance): void {
255
+ // WebSocket endpoint for agents to connect
256
+ app.get('/api/a2a/bridge', { websocket: true }, (socket, req) => {
257
+ handleBridgeConnection(socket, req);
258
+ });
259
+
260
+ // HTTP proxy endpoint — all paths under /api/a2a/bridge/proxy/{agentId}/
261
+ app.route({
262
+ method: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
263
+ url: '/api/a2a/bridge/proxy/*',
264
+ handler: handleBridgeProxy,
265
+ });
266
+ }