@intx/hub-sessions 0.1.2 → 0.2.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.
- package/LICENSE +176 -0
- package/README.md +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,2217 +0,0 @@
|
|
|
1
|
-
import { sign as nodeSign } from "node:crypto";
|
|
2
|
-
import { describe, test, expect, beforeEach } from "bun:test";
|
|
3
|
-
import { generateKeyPair, importPrivateKeyBytes } from "@intx/crypto-node";
|
|
4
|
-
import { hexDecode, hexEncode, parseAgentAddress } from "@intx/types";
|
|
5
|
-
import { createSidecarRouter, type WsHandle } from "./sidecar-handler";
|
|
6
|
-
|
|
7
|
-
function signChallenge(
|
|
8
|
-
nonce: string,
|
|
9
|
-
address: string,
|
|
10
|
-
privateKeyBytes: Uint8Array,
|
|
11
|
-
): string {
|
|
12
|
-
const nonceBytes = hexDecode(nonce);
|
|
13
|
-
const addressBytes = new TextEncoder().encode(address);
|
|
14
|
-
const payload = new Uint8Array(nonceBytes.length + addressBytes.length);
|
|
15
|
-
payload.set(nonceBytes);
|
|
16
|
-
payload.set(addressBytes, nonceBytes.length);
|
|
17
|
-
const privateKey = importPrivateKeyBytes(privateKeyBytes);
|
|
18
|
-
const sig = nodeSign(null, payload, privateKey);
|
|
19
|
-
return hexEncode(new Uint8Array(sig));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function createMockWs(): WsHandle & { sent: string[]; closed: boolean } {
|
|
23
|
-
return {
|
|
24
|
-
sent: [],
|
|
25
|
-
closed: false,
|
|
26
|
-
send(data: string) {
|
|
27
|
-
this.sent.push(data);
|
|
28
|
-
},
|
|
29
|
-
close() {
|
|
30
|
-
this.closed = true;
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function lastSent(ws: ReturnType<typeof createMockWs>) {
|
|
36
|
-
const last = ws.sent[ws.sent.length - 1];
|
|
37
|
-
if (last === undefined) throw new Error("No messages sent");
|
|
38
|
-
return JSON.parse(last);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe("SidecarRouter", () => {
|
|
42
|
-
let router: ReturnType<typeof createSidecarRouter>;
|
|
43
|
-
|
|
44
|
-
const TEST_HUB_KEY = "a".repeat(64);
|
|
45
|
-
|
|
46
|
-
beforeEach(() => {
|
|
47
|
-
router = createSidecarRouter({
|
|
48
|
-
requestTimeoutMs: 500,
|
|
49
|
-
hubPublicKey: TEST_HUB_KEY,
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("registration", () => {
|
|
54
|
-
test("register frame populates routing table", () => {
|
|
55
|
-
const ws = createMockWs();
|
|
56
|
-
router.handleOpen(ws);
|
|
57
|
-
router.handleMessage(
|
|
58
|
-
ws,
|
|
59
|
-
JSON.stringify({
|
|
60
|
-
type: "register",
|
|
61
|
-
sidecarId: "sc-1",
|
|
62
|
-
token: "tok",
|
|
63
|
-
agentAddresses: ["agent-a@local", "agent-b@local"],
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
expect(router.getConnectedSidecars()).toEqual(["sc-1"]);
|
|
68
|
-
expect(router.getRoutableAddresses().sort()).toEqual([
|
|
69
|
-
"agent-a@local",
|
|
70
|
-
"agent-b@local",
|
|
71
|
-
]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("re-registration updates addresses", () => {
|
|
75
|
-
const ws = createMockWs();
|
|
76
|
-
router.handleOpen(ws);
|
|
77
|
-
router.handleMessage(
|
|
78
|
-
ws,
|
|
79
|
-
JSON.stringify({
|
|
80
|
-
type: "register",
|
|
81
|
-
sidecarId: "sc-1",
|
|
82
|
-
token: "tok",
|
|
83
|
-
agentAddresses: ["agent-a@local"],
|
|
84
|
-
}),
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
router.handleMessage(
|
|
88
|
-
ws,
|
|
89
|
-
JSON.stringify({
|
|
90
|
-
type: "register",
|
|
91
|
-
sidecarId: "sc-1",
|
|
92
|
-
token: "tok",
|
|
93
|
-
agentAddresses: ["agent-c@local"],
|
|
94
|
-
}),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
expect(router.getRoutableAddresses()).toEqual(["agent-c@local"]);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("disconnect cleans up routing table", () => {
|
|
101
|
-
const ws = createMockWs();
|
|
102
|
-
router.handleOpen(ws);
|
|
103
|
-
router.handleMessage(
|
|
104
|
-
ws,
|
|
105
|
-
JSON.stringify({
|
|
106
|
-
type: "register",
|
|
107
|
-
sidecarId: "sc-1",
|
|
108
|
-
token: "tok",
|
|
109
|
-
agentAddresses: ["agent-a@local"],
|
|
110
|
-
}),
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
router.handleClose(ws);
|
|
114
|
-
expect(router.getConnectedSidecars()).toEqual([]);
|
|
115
|
-
expect(router.getRoutableAddresses()).toEqual([]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("invalid token closes connection", () => {
|
|
119
|
-
const router = createSidecarRouter({
|
|
120
|
-
validateToken: () => false,
|
|
121
|
-
});
|
|
122
|
-
const ws = createMockWs();
|
|
123
|
-
router.handleOpen(ws);
|
|
124
|
-
router.handleMessage(
|
|
125
|
-
ws,
|
|
126
|
-
JSON.stringify({
|
|
127
|
-
type: "register",
|
|
128
|
-
sidecarId: "sc-1",
|
|
129
|
-
token: "bad",
|
|
130
|
-
agentAddresses: [],
|
|
131
|
-
}),
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
expect(ws.closed).toBe(true);
|
|
135
|
-
expect(router.getConnectedSidecars()).toEqual([]);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("re-registration by another sidecar cleans ghost from old connection", () => {
|
|
139
|
-
const ws1 = createMockWs();
|
|
140
|
-
router.handleOpen(ws1);
|
|
141
|
-
router.handleMessage(
|
|
142
|
-
ws1,
|
|
143
|
-
JSON.stringify({
|
|
144
|
-
type: "register",
|
|
145
|
-
sidecarId: "sc-1",
|
|
146
|
-
token: "tok",
|
|
147
|
-
agentAddresses: ["agent@local", "other@local"],
|
|
148
|
-
}),
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const ws2 = createMockWs();
|
|
152
|
-
router.handleOpen(ws2);
|
|
153
|
-
router.handleMessage(
|
|
154
|
-
ws2,
|
|
155
|
-
JSON.stringify({
|
|
156
|
-
type: "register",
|
|
157
|
-
sidecarId: "sc-2",
|
|
158
|
-
token: "tok",
|
|
159
|
-
agentAddresses: ["agent@local"],
|
|
160
|
-
}),
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
// ws2 now owns agent@local. Closing ws1 should only remove
|
|
164
|
-
// other@local (which ws1 still owns), not agent@local.
|
|
165
|
-
router.handleClose(ws1);
|
|
166
|
-
|
|
167
|
-
expect(router.getRoutableAddresses()).toContain("agent@local");
|
|
168
|
-
expect(router.getRoutableAddresses()).not.toContain("other@local");
|
|
169
|
-
expect(router.routeMail("agent@local", "hello")).toBe(true);
|
|
170
|
-
expect(ws2.sent).toHaveLength(1);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe("mail routing", () => {
|
|
175
|
-
test("routes mail between two sidecars", () => {
|
|
176
|
-
const ws1 = createMockWs();
|
|
177
|
-
const ws2 = createMockWs();
|
|
178
|
-
router.handleOpen(ws1);
|
|
179
|
-
router.handleOpen(ws2);
|
|
180
|
-
|
|
181
|
-
router.handleMessage(
|
|
182
|
-
ws1,
|
|
183
|
-
JSON.stringify({
|
|
184
|
-
type: "register",
|
|
185
|
-
sidecarId: "sc-1",
|
|
186
|
-
token: "tok",
|
|
187
|
-
agentAddresses: ["sender@local"],
|
|
188
|
-
}),
|
|
189
|
-
);
|
|
190
|
-
router.handleMessage(
|
|
191
|
-
ws2,
|
|
192
|
-
JSON.stringify({
|
|
193
|
-
type: "register",
|
|
194
|
-
sidecarId: "sc-2",
|
|
195
|
-
token: "tok",
|
|
196
|
-
agentAddresses: ["receiver@local"],
|
|
197
|
-
}),
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
router.handleMessage(
|
|
201
|
-
ws1,
|
|
202
|
-
JSON.stringify({
|
|
203
|
-
type: "mail.outbound",
|
|
204
|
-
rawMessage: "dGVzdA==",
|
|
205
|
-
recipients: ["receiver@local"],
|
|
206
|
-
}),
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
const delivered = lastSent(ws2);
|
|
210
|
-
expect(delivered.type).toBe("mail.inbound");
|
|
211
|
-
expect(delivered.agentAddress).toBe("receiver@local");
|
|
212
|
-
expect(delivered.rawMessage).toBe("dGVzdA==");
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("routeMail returns false for unknown address", () => {
|
|
216
|
-
expect(router.routeMail("nobody@local", "dGVzdA==")).toBe(false);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("routeMail returns true for routable address", () => {
|
|
220
|
-
const ws = createMockWs();
|
|
221
|
-
router.handleOpen(ws);
|
|
222
|
-
router.handleMessage(
|
|
223
|
-
ws,
|
|
224
|
-
JSON.stringify({
|
|
225
|
-
type: "register",
|
|
226
|
-
sidecarId: "sc-1",
|
|
227
|
-
token: "tok",
|
|
228
|
-
agentAddresses: ["agent@local"],
|
|
229
|
-
}),
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
expect(router.routeMail("agent@local", "dGVzdA==")).toBe(true);
|
|
233
|
-
const delivered = lastSent(ws);
|
|
234
|
-
expect(delivered.type).toBe("mail.inbound");
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("unroutable mail emits mail.outbound.undelivered", () => {
|
|
238
|
-
const outbound: { rawMessage: string; recipients: string[] }[] = [];
|
|
239
|
-
const router = createSidecarRouter({});
|
|
240
|
-
router.events.on("mail.outbound.undelivered", (event) => {
|
|
241
|
-
outbound.push({
|
|
242
|
-
rawMessage: event.rawMessage,
|
|
243
|
-
recipients: event.recipients,
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
const ws = createMockWs();
|
|
248
|
-
router.handleOpen(ws);
|
|
249
|
-
router.handleMessage(
|
|
250
|
-
ws,
|
|
251
|
-
JSON.stringify({
|
|
252
|
-
type: "register",
|
|
253
|
-
sidecarId: "sc-1",
|
|
254
|
-
token: "tok",
|
|
255
|
-
agentAddresses: ["sender@local"],
|
|
256
|
-
}),
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
router.handleMessage(
|
|
260
|
-
ws,
|
|
261
|
-
JSON.stringify({
|
|
262
|
-
type: "mail.outbound",
|
|
263
|
-
rawMessage: "dGVzdA==",
|
|
264
|
-
recipients: ["external@remote"],
|
|
265
|
-
}),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
expect(outbound).toHaveLength(1);
|
|
269
|
-
expect(outbound[0]?.recipients).toEqual(["external@remote"]);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
test("agent reply to non-agent address still persists outbound record", async () => {
|
|
273
|
-
// When an agent replies to a human user (usr_ address), the
|
|
274
|
-
// persistMail lookup must still persist at least the outbound
|
|
275
|
-
// record on the sender's session and emit mail.persisted so a
|
|
276
|
-
// mail.delivered SSE event is dispatched.
|
|
277
|
-
const persisted: { id: string; address: string }[] = [];
|
|
278
|
-
const router = createSidecarRouter({
|
|
279
|
-
lookups: {
|
|
280
|
-
persistMail: async ({ senderAddress, recipients }) => {
|
|
281
|
-
// Mirrors the fixed persistMail: always create the
|
|
282
|
-
// outbound record for the sender, and only create
|
|
283
|
-
// inbound records for recipients that are agent
|
|
284
|
-
// instances (ins_ prefix).
|
|
285
|
-
const results: {
|
|
286
|
-
id: string;
|
|
287
|
-
direction: "inbound" | "outbound";
|
|
288
|
-
instanceId: string | null;
|
|
289
|
-
address: string;
|
|
290
|
-
createdAt: Date;
|
|
291
|
-
}[] = [
|
|
292
|
-
{
|
|
293
|
-
id: "mail_outbound",
|
|
294
|
-
direction: "outbound",
|
|
295
|
-
instanceId:
|
|
296
|
-
parseAgentAddress(senderAddress)?.instanceId ?? senderAddress,
|
|
297
|
-
address: senderAddress,
|
|
298
|
-
createdAt: new Date(),
|
|
299
|
-
},
|
|
300
|
-
];
|
|
301
|
-
for (const addr of recipients) {
|
|
302
|
-
if (addr.startsWith("ins_")) {
|
|
303
|
-
results.push({
|
|
304
|
-
id: `mail_in_${addr}`,
|
|
305
|
-
direction: "inbound",
|
|
306
|
-
instanceId: parseAgentAddress(addr)?.instanceId ?? addr,
|
|
307
|
-
address: addr,
|
|
308
|
-
createdAt: new Date(),
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return results;
|
|
313
|
-
},
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
router.events.on("mail.persisted", (row) => {
|
|
317
|
-
persisted.push({ id: row.id, address: row.address });
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
const ws = createMockWs();
|
|
321
|
-
router.handleOpen(ws);
|
|
322
|
-
router.handleMessage(
|
|
323
|
-
ws,
|
|
324
|
-
JSON.stringify({
|
|
325
|
-
type: "register",
|
|
326
|
-
sidecarId: "sc-1",
|
|
327
|
-
token: "tok",
|
|
328
|
-
agentAddresses: ["ins_sender@tenant.example"],
|
|
329
|
-
}),
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
// Agent sends a reply to a human user address.
|
|
333
|
-
router.handleMessage(
|
|
334
|
-
ws,
|
|
335
|
-
JSON.stringify({
|
|
336
|
-
type: "mail.outbound",
|
|
337
|
-
delivered: true,
|
|
338
|
-
senderAddress: "ins_sender@tenant.example",
|
|
339
|
-
rawMessage: btoa("test message"),
|
|
340
|
-
recipients: ["usr_human@tenant.example"],
|
|
341
|
-
}),
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
// Allow the async handleMailPersist to settle.
|
|
345
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
346
|
-
|
|
347
|
-
// The outbound record for the sender must always be persisted,
|
|
348
|
-
// regardless of whether recipients are agent instances. No inbound
|
|
349
|
-
// record should be created for the non-agent recipient.
|
|
350
|
-
expect(persisted).toHaveLength(1);
|
|
351
|
-
expect(persisted[0]?.address).toBe("ins_sender@tenant.example");
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("persistMail lookup result fans out as mail.persisted events", async () => {
|
|
355
|
-
const persisted: { id: string; address: string }[] = [];
|
|
356
|
-
const router = createSidecarRouter({
|
|
357
|
-
lookups: {
|
|
358
|
-
persistMail: async ({ senderAddress, recipients }) => {
|
|
359
|
-
return [
|
|
360
|
-
{
|
|
361
|
-
id: "mail_out",
|
|
362
|
-
direction: "outbound" as const,
|
|
363
|
-
instanceId:
|
|
364
|
-
parseAgentAddress(senderAddress)?.instanceId ?? senderAddress,
|
|
365
|
-
address: senderAddress,
|
|
366
|
-
createdAt: new Date(),
|
|
367
|
-
},
|
|
368
|
-
...recipients.map((addr) => ({
|
|
369
|
-
id: `mail_in_${addr}`,
|
|
370
|
-
direction: "inbound" as const,
|
|
371
|
-
instanceId: parseAgentAddress(addr)?.instanceId ?? addr,
|
|
372
|
-
address: addr,
|
|
373
|
-
createdAt: new Date(),
|
|
374
|
-
})),
|
|
375
|
-
];
|
|
376
|
-
},
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
router.events.on("mail.persisted", (row) => {
|
|
380
|
-
persisted.push({ id: row.id, address: row.address });
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
const ws = createMockWs();
|
|
384
|
-
router.handleOpen(ws);
|
|
385
|
-
router.handleMessage(
|
|
386
|
-
ws,
|
|
387
|
-
JSON.stringify({
|
|
388
|
-
type: "register",
|
|
389
|
-
sidecarId: "sc-1",
|
|
390
|
-
token: "tok",
|
|
391
|
-
agentAddresses: ["ins_sender@tenant.example"],
|
|
392
|
-
}),
|
|
393
|
-
);
|
|
394
|
-
|
|
395
|
-
// Agent sends mail to another agent (both are instances).
|
|
396
|
-
router.handleMessage(
|
|
397
|
-
ws,
|
|
398
|
-
JSON.stringify({
|
|
399
|
-
type: "mail.outbound",
|
|
400
|
-
delivered: true,
|
|
401
|
-
senderAddress: "ins_sender@tenant.example",
|
|
402
|
-
rawMessage: btoa("test message"),
|
|
403
|
-
recipients: ["ins_receiver@tenant.example"],
|
|
404
|
-
}),
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
408
|
-
|
|
409
|
-
// Both outbound (sender) and inbound (receiver) records persisted.
|
|
410
|
-
expect(persisted).toHaveLength(2);
|
|
411
|
-
expect(persisted[0]?.address).toBe("ins_sender@tenant.example");
|
|
412
|
-
expect(persisted[1]?.address).toBe("ins_receiver@tenant.example");
|
|
413
|
-
});
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
describe("agent lifecycle", () => {
|
|
417
|
-
test("agent.deploy sends frame and resolves on ack", async () => {
|
|
418
|
-
const ws = createMockWs();
|
|
419
|
-
router.handleOpen(ws);
|
|
420
|
-
router.handleMessage(
|
|
421
|
-
ws,
|
|
422
|
-
JSON.stringify({
|
|
423
|
-
type: "register",
|
|
424
|
-
sidecarId: "sc-1",
|
|
425
|
-
token: "tok",
|
|
426
|
-
agentAddresses: [],
|
|
427
|
-
}),
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
const config = {
|
|
431
|
-
sessionId: "ses_test",
|
|
432
|
-
agentId: "a1",
|
|
433
|
-
tenantId: "t1",
|
|
434
|
-
principalId: "prin_test",
|
|
435
|
-
agentAddress: "new-agent@local",
|
|
436
|
-
systemPrompt: "test",
|
|
437
|
-
tools: [],
|
|
438
|
-
grants: [],
|
|
439
|
-
sources: [
|
|
440
|
-
{
|
|
441
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
442
|
-
provider: "anthropic",
|
|
443
|
-
baseURL: "https://api.anthropic.com",
|
|
444
|
-
apiKey: "sk-test",
|
|
445
|
-
model: "claude-sonnet-4-20250514",
|
|
446
|
-
},
|
|
447
|
-
],
|
|
448
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
const promise = router.sendAgentDeploy("new-agent@local", config);
|
|
452
|
-
|
|
453
|
-
const frame = lastSent(ws);
|
|
454
|
-
expect(frame.type).toBe("agent.deploy");
|
|
455
|
-
expect(frame.config.agentAddress).toBe("new-agent@local");
|
|
456
|
-
|
|
457
|
-
router.handleMessage(
|
|
458
|
-
ws,
|
|
459
|
-
JSON.stringify({
|
|
460
|
-
type: "agent.deploy.ack",
|
|
461
|
-
agentAddress: "new-agent@local",
|
|
462
|
-
publicKey: "deadbeef",
|
|
463
|
-
}),
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
await promise;
|
|
467
|
-
expect(router.getRoutableAddresses()).toContain("new-agent@local");
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test("agent.deploy.ack invokes subscribers before resolving", async () => {
|
|
471
|
-
const ackCalls: { address: string; publicKey: string }[] = [];
|
|
472
|
-
const router = createSidecarRouter({
|
|
473
|
-
requestTimeoutMs: 500,
|
|
474
|
-
hubPublicKey: TEST_HUB_KEY,
|
|
475
|
-
});
|
|
476
|
-
router.events.on("agent.deploy.ack", ({ agentAddress, publicKey }) => {
|
|
477
|
-
ackCalls.push({ address: agentAddress, publicKey });
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const ws = createMockWs();
|
|
481
|
-
router.handleOpen(ws);
|
|
482
|
-
router.handleMessage(
|
|
483
|
-
ws,
|
|
484
|
-
JSON.stringify({
|
|
485
|
-
type: "register",
|
|
486
|
-
sidecarId: "sc-1",
|
|
487
|
-
token: "tok",
|
|
488
|
-
agentAddresses: [],
|
|
489
|
-
}),
|
|
490
|
-
);
|
|
491
|
-
|
|
492
|
-
const config = {
|
|
493
|
-
sessionId: "ses_test",
|
|
494
|
-
agentId: "a1",
|
|
495
|
-
tenantId: "t1",
|
|
496
|
-
principalId: "prin_test",
|
|
497
|
-
agentAddress: "ack-agent@local",
|
|
498
|
-
systemPrompt: "test",
|
|
499
|
-
tools: [],
|
|
500
|
-
grants: [],
|
|
501
|
-
sources: [
|
|
502
|
-
{
|
|
503
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
504
|
-
provider: "anthropic",
|
|
505
|
-
baseURL: "https://api.anthropic.com",
|
|
506
|
-
apiKey: "sk-test",
|
|
507
|
-
model: "claude-sonnet-4-20250514",
|
|
508
|
-
},
|
|
509
|
-
],
|
|
510
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
const promise = router.sendAgentDeploy("ack-agent@local", config);
|
|
514
|
-
|
|
515
|
-
router.handleMessage(
|
|
516
|
-
ws,
|
|
517
|
-
JSON.stringify({
|
|
518
|
-
type: "agent.deploy.ack",
|
|
519
|
-
agentAddress: "ack-agent@local",
|
|
520
|
-
publicKey: "aabbccdd",
|
|
521
|
-
}),
|
|
522
|
-
);
|
|
523
|
-
|
|
524
|
-
await promise;
|
|
525
|
-
expect(ackCalls).toEqual([
|
|
526
|
-
{ address: "ack-agent@local", publicKey: "aabbccdd" },
|
|
527
|
-
]);
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
test("agent.deploy rejects when agent.deploy.ack subscriber throws", async () => {
|
|
531
|
-
const router = createSidecarRouter({
|
|
532
|
-
requestTimeoutMs: 500,
|
|
533
|
-
hubPublicKey: TEST_HUB_KEY,
|
|
534
|
-
});
|
|
535
|
-
router.events.on("agent.deploy.ack", () => {
|
|
536
|
-
throw new Error("DB write failed");
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
const ws = createMockWs();
|
|
540
|
-
router.handleOpen(ws);
|
|
541
|
-
router.handleMessage(
|
|
542
|
-
ws,
|
|
543
|
-
JSON.stringify({
|
|
544
|
-
type: "register",
|
|
545
|
-
sidecarId: "sc-1",
|
|
546
|
-
token: "tok",
|
|
547
|
-
agentAddresses: [],
|
|
548
|
-
}),
|
|
549
|
-
);
|
|
550
|
-
|
|
551
|
-
const config = {
|
|
552
|
-
sessionId: "ses_test",
|
|
553
|
-
agentId: "a1",
|
|
554
|
-
tenantId: "t1",
|
|
555
|
-
principalId: "prin_test",
|
|
556
|
-
agentAddress: "fail-ack@local",
|
|
557
|
-
systemPrompt: "test",
|
|
558
|
-
tools: [],
|
|
559
|
-
grants: [],
|
|
560
|
-
sources: [
|
|
561
|
-
{
|
|
562
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
563
|
-
provider: "anthropic",
|
|
564
|
-
baseURL: "https://api.anthropic.com",
|
|
565
|
-
apiKey: "sk-test",
|
|
566
|
-
model: "claude-sonnet-4-20250514",
|
|
567
|
-
},
|
|
568
|
-
],
|
|
569
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
const promise = router.sendAgentDeploy("fail-ack@local", config);
|
|
573
|
-
|
|
574
|
-
router.handleMessage(
|
|
575
|
-
ws,
|
|
576
|
-
JSON.stringify({
|
|
577
|
-
type: "agent.deploy.ack",
|
|
578
|
-
agentAddress: "fail-ack@local",
|
|
579
|
-
publicKey: "aabbccdd",
|
|
580
|
-
}),
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
await expect(promise).rejects.toThrow("Failed to store public key");
|
|
584
|
-
expect(router.getRoutableAddresses()).not.toContain("fail-ack@local");
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
test("agent.deploy rolls back routing on error", async () => {
|
|
588
|
-
const ws = createMockWs();
|
|
589
|
-
router.handleOpen(ws);
|
|
590
|
-
router.handleMessage(
|
|
591
|
-
ws,
|
|
592
|
-
JSON.stringify({
|
|
593
|
-
type: "register",
|
|
594
|
-
sidecarId: "sc-1",
|
|
595
|
-
token: "tok",
|
|
596
|
-
agentAddresses: [],
|
|
597
|
-
}),
|
|
598
|
-
);
|
|
599
|
-
|
|
600
|
-
const config = {
|
|
601
|
-
sessionId: "ses_test",
|
|
602
|
-
agentId: "a1",
|
|
603
|
-
tenantId: "t1",
|
|
604
|
-
principalId: "prin_test",
|
|
605
|
-
agentAddress: "fail-agent@local",
|
|
606
|
-
systemPrompt: "test",
|
|
607
|
-
tools: [],
|
|
608
|
-
grants: [],
|
|
609
|
-
sources: [
|
|
610
|
-
{
|
|
611
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
612
|
-
provider: "anthropic",
|
|
613
|
-
baseURL: "https://api.anthropic.com",
|
|
614
|
-
apiKey: "sk-test",
|
|
615
|
-
model: "claude-sonnet-4-20250514",
|
|
616
|
-
},
|
|
617
|
-
],
|
|
618
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
const promise = router.sendAgentDeploy("fail-agent@local", config);
|
|
622
|
-
|
|
623
|
-
router.handleMessage(
|
|
624
|
-
ws,
|
|
625
|
-
JSON.stringify({
|
|
626
|
-
type: "agent.error",
|
|
627
|
-
agentAddress: "fail-agent@local",
|
|
628
|
-
error: "provider failed",
|
|
629
|
-
}),
|
|
630
|
-
);
|
|
631
|
-
|
|
632
|
-
await expect(promise).rejects.toThrow("provider failed");
|
|
633
|
-
expect(router.getRoutableAddresses()).not.toContain("fail-agent@local");
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
test("agent.undeploy sends frame and removes routing after ack", async () => {
|
|
637
|
-
const ws = createMockWs();
|
|
638
|
-
router.handleOpen(ws);
|
|
639
|
-
router.handleMessage(
|
|
640
|
-
ws,
|
|
641
|
-
JSON.stringify({
|
|
642
|
-
type: "register",
|
|
643
|
-
sidecarId: "sc-1",
|
|
644
|
-
token: "tok",
|
|
645
|
-
agentAddresses: ["agent@local"],
|
|
646
|
-
}),
|
|
647
|
-
);
|
|
648
|
-
|
|
649
|
-
const promise = router.sendAgentUndeploy("agent@local", "session_ended");
|
|
650
|
-
const frame = lastSent(ws);
|
|
651
|
-
expect(frame.type).toBe("agent.undeploy");
|
|
652
|
-
expect(frame.agentAddress).toBe("agent@local");
|
|
653
|
-
expect(frame.reason).toBe("session_ended");
|
|
654
|
-
|
|
655
|
-
// Routing persists until the ack arrives.
|
|
656
|
-
expect(router.getRoutableAddresses()).toContain("agent@local");
|
|
657
|
-
|
|
658
|
-
router.handleMessage(
|
|
659
|
-
ws,
|
|
660
|
-
JSON.stringify({
|
|
661
|
-
type: "agent.undeploy.ack",
|
|
662
|
-
agentAddress: "agent@local",
|
|
663
|
-
statePushed: true,
|
|
664
|
-
}),
|
|
665
|
-
);
|
|
666
|
-
|
|
667
|
-
await promise;
|
|
668
|
-
expect(router.getRoutableAddresses()).not.toContain("agent@local");
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
test("deploy request times out", async () => {
|
|
672
|
-
const ws = createMockWs();
|
|
673
|
-
router.handleOpen(ws);
|
|
674
|
-
router.handleMessage(
|
|
675
|
-
ws,
|
|
676
|
-
JSON.stringify({
|
|
677
|
-
type: "register",
|
|
678
|
-
sidecarId: "sc-1",
|
|
679
|
-
token: "tok",
|
|
680
|
-
agentAddresses: [],
|
|
681
|
-
}),
|
|
682
|
-
);
|
|
683
|
-
|
|
684
|
-
const config = {
|
|
685
|
-
sessionId: "ses_test",
|
|
686
|
-
agentId: "a1",
|
|
687
|
-
tenantId: "t1",
|
|
688
|
-
principalId: "prin_test",
|
|
689
|
-
agentAddress: "timeout@local",
|
|
690
|
-
systemPrompt: "test",
|
|
691
|
-
tools: [],
|
|
692
|
-
grants: [],
|
|
693
|
-
sources: [
|
|
694
|
-
{
|
|
695
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
696
|
-
provider: "anthropic",
|
|
697
|
-
baseURL: "https://api.anthropic.com",
|
|
698
|
-
apiKey: "sk-test",
|
|
699
|
-
model: "claude-sonnet-4-20250514",
|
|
700
|
-
},
|
|
701
|
-
],
|
|
702
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
await expect(
|
|
706
|
-
router.sendAgentDeploy("timeout@local", config),
|
|
707
|
-
).rejects.toThrow(/timed out/);
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
test("undeploy to unknown agent rejects immediately", async () => {
|
|
711
|
-
await expect(
|
|
712
|
-
router.sendAgentUndeploy("unknown@local", "gone"),
|
|
713
|
-
).rejects.toThrow(/No sidecar connected/);
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test("disconnect during undeploy does not queue messages", async () => {
|
|
717
|
-
const ws = createMockWs();
|
|
718
|
-
router.handleOpen(ws);
|
|
719
|
-
router.handleMessage(
|
|
720
|
-
ws,
|
|
721
|
-
JSON.stringify({
|
|
722
|
-
type: "register",
|
|
723
|
-
sidecarId: "sc-1",
|
|
724
|
-
token: "tok",
|
|
725
|
-
agentAddresses: ["undeploy-dc@local"],
|
|
726
|
-
}),
|
|
727
|
-
);
|
|
728
|
-
|
|
729
|
-
// Start an undeploy but disconnect before the ack arrives.
|
|
730
|
-
const promise = router.sendAgentUndeploy("undeploy-dc@local", "teardown");
|
|
731
|
-
router.handleClose(ws);
|
|
732
|
-
await expect(promise).rejects.toThrow(/disconnected/);
|
|
733
|
-
|
|
734
|
-
// The address should not be routable after undeploy + disconnect.
|
|
735
|
-
expect(router.getRoutableAddresses()).not.toContain("undeploy-dc@local");
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("disconnect rejects pending deploy", async () => {
|
|
739
|
-
const ws = createMockWs();
|
|
740
|
-
router.handleOpen(ws);
|
|
741
|
-
router.handleMessage(
|
|
742
|
-
ws,
|
|
743
|
-
JSON.stringify({
|
|
744
|
-
type: "register",
|
|
745
|
-
sidecarId: "sc-1",
|
|
746
|
-
token: "tok",
|
|
747
|
-
agentAddresses: [],
|
|
748
|
-
}),
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
const config = {
|
|
752
|
-
sessionId: "ses_test",
|
|
753
|
-
agentId: "a1",
|
|
754
|
-
tenantId: "t1",
|
|
755
|
-
principalId: "prin_test",
|
|
756
|
-
agentAddress: "dc-agent@local",
|
|
757
|
-
systemPrompt: "test",
|
|
758
|
-
tools: [],
|
|
759
|
-
grants: [],
|
|
760
|
-
sources: [
|
|
761
|
-
{
|
|
762
|
-
id: "anthropic:claude-sonnet-4-20250514",
|
|
763
|
-
provider: "anthropic",
|
|
764
|
-
baseURL: "https://api.anthropic.com",
|
|
765
|
-
apiKey: "sk-test",
|
|
766
|
-
model: "claude-sonnet-4-20250514",
|
|
767
|
-
},
|
|
768
|
-
],
|
|
769
|
-
defaultSource: "anthropic:claude-sonnet-4-20250514",
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
const promise = router.sendAgentDeploy("dc-agent@local", config);
|
|
773
|
-
router.handleClose(ws);
|
|
774
|
-
|
|
775
|
-
await expect(promise).rejects.toThrow(/disconnected/);
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
test("abort preserves routing when address re-registered during await", async () => {
|
|
779
|
-
const ws1 = createMockWs();
|
|
780
|
-
router.handleOpen(ws1);
|
|
781
|
-
router.handleMessage(
|
|
782
|
-
ws1,
|
|
783
|
-
JSON.stringify({
|
|
784
|
-
type: "register",
|
|
785
|
-
sidecarId: "sc-1",
|
|
786
|
-
token: "tok",
|
|
787
|
-
agentAddresses: ["agent@local"],
|
|
788
|
-
}),
|
|
789
|
-
);
|
|
790
|
-
|
|
791
|
-
const promise = router.sendSessionAbort("agent@local", "user_disconnect");
|
|
792
|
-
const frame = lastSent(ws1);
|
|
793
|
-
|
|
794
|
-
const ws2 = createMockWs();
|
|
795
|
-
router.handleOpen(ws2);
|
|
796
|
-
router.handleMessage(
|
|
797
|
-
ws2,
|
|
798
|
-
JSON.stringify({
|
|
799
|
-
type: "register",
|
|
800
|
-
sidecarId: "sc-2",
|
|
801
|
-
token: "tok",
|
|
802
|
-
agentAddresses: ["agent@local"],
|
|
803
|
-
}),
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
router.handleMessage(
|
|
807
|
-
ws1,
|
|
808
|
-
JSON.stringify({ type: "session.ack", requestId: frame.requestId }),
|
|
809
|
-
);
|
|
810
|
-
await promise;
|
|
811
|
-
|
|
812
|
-
expect(router.getRoutableAddresses()).toContain("agent@local");
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
test("closing stale sidecar after reconnect-during-abort does not evict address", async () => {
|
|
816
|
-
const ws1 = createMockWs();
|
|
817
|
-
router.handleOpen(ws1);
|
|
818
|
-
router.handleMessage(
|
|
819
|
-
ws1,
|
|
820
|
-
JSON.stringify({
|
|
821
|
-
type: "register",
|
|
822
|
-
sidecarId: "sc-1",
|
|
823
|
-
token: "tok",
|
|
824
|
-
agentAddresses: ["agent@local"],
|
|
825
|
-
}),
|
|
826
|
-
);
|
|
827
|
-
|
|
828
|
-
const promise = router.sendSessionAbort("agent@local", "user_disconnect");
|
|
829
|
-
const frame = lastSent(ws1);
|
|
830
|
-
|
|
831
|
-
const ws2 = createMockWs();
|
|
832
|
-
router.handleOpen(ws2);
|
|
833
|
-
router.handleMessage(
|
|
834
|
-
ws2,
|
|
835
|
-
JSON.stringify({
|
|
836
|
-
type: "register",
|
|
837
|
-
sidecarId: "sc-2",
|
|
838
|
-
token: "tok",
|
|
839
|
-
agentAddresses: ["agent@local"],
|
|
840
|
-
}),
|
|
841
|
-
);
|
|
842
|
-
|
|
843
|
-
router.handleMessage(
|
|
844
|
-
ws1,
|
|
845
|
-
JSON.stringify({ type: "session.ack", requestId: frame.requestId }),
|
|
846
|
-
);
|
|
847
|
-
await promise;
|
|
848
|
-
|
|
849
|
-
router.handleClose(ws1);
|
|
850
|
-
|
|
851
|
-
expect(router.getRoutableAddresses()).toContain("agent@local");
|
|
852
|
-
});
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
describe("agent events", () => {
|
|
856
|
-
test("agent.event frames are forwarded to subscribers", () => {
|
|
857
|
-
const events: { addr: string; sid: string; event: unknown }[] = [];
|
|
858
|
-
const router = createSidecarRouter({});
|
|
859
|
-
router.events.on("agent.event", ({ agentAddress, sessionId, event }) => {
|
|
860
|
-
events.push({ addr: agentAddress, sid: sessionId, event });
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
const ws = createMockWs();
|
|
864
|
-
router.handleOpen(ws);
|
|
865
|
-
router.handleMessage(
|
|
866
|
-
ws,
|
|
867
|
-
JSON.stringify({
|
|
868
|
-
type: "register",
|
|
869
|
-
sidecarId: "sc-1",
|
|
870
|
-
token: "tok",
|
|
871
|
-
agentAddresses: [],
|
|
872
|
-
}),
|
|
873
|
-
);
|
|
874
|
-
|
|
875
|
-
router.handleMessage(
|
|
876
|
-
ws,
|
|
877
|
-
JSON.stringify({
|
|
878
|
-
type: "agent.event",
|
|
879
|
-
agentAddress: "agent@local",
|
|
880
|
-
sessionId: "sess-1",
|
|
881
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
882
|
-
}),
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
expect(events).toHaveLength(1);
|
|
886
|
-
expect(events[0]?.addr).toBe("agent@local");
|
|
887
|
-
expect(events[0]?.event).toEqual({
|
|
888
|
-
type: "reactor.start",
|
|
889
|
-
seq: 0,
|
|
890
|
-
data: {},
|
|
891
|
-
});
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
test("agent.event frames are emitted on router.events", () => {
|
|
895
|
-
const seen: { addr: string; sid: string }[] = [];
|
|
896
|
-
const router = createSidecarRouter({});
|
|
897
|
-
router.events.on("agent.event", ({ agentAddress, sessionId }) => {
|
|
898
|
-
seen.push({ addr: agentAddress, sid: sessionId });
|
|
899
|
-
});
|
|
900
|
-
|
|
901
|
-
const ws = createMockWs();
|
|
902
|
-
router.handleOpen(ws);
|
|
903
|
-
router.handleMessage(
|
|
904
|
-
ws,
|
|
905
|
-
JSON.stringify({
|
|
906
|
-
type: "register",
|
|
907
|
-
sidecarId: "sc-1",
|
|
908
|
-
token: "tok",
|
|
909
|
-
agentAddresses: [],
|
|
910
|
-
}),
|
|
911
|
-
);
|
|
912
|
-
router.handleMessage(
|
|
913
|
-
ws,
|
|
914
|
-
JSON.stringify({
|
|
915
|
-
type: "agent.event",
|
|
916
|
-
agentAddress: "agent@local",
|
|
917
|
-
sessionId: "sess-1",
|
|
918
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
919
|
-
}),
|
|
920
|
-
);
|
|
921
|
-
|
|
922
|
-
expect(seen).toEqual([{ addr: "agent@local", sid: "sess-1" }]);
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
test("sidecar.disconnect is emitted on router.events", () => {
|
|
926
|
-
const router = createSidecarRouter({});
|
|
927
|
-
const seen: string[][] = [];
|
|
928
|
-
router.events.on("sidecar.disconnect", ({ agentAddresses }) => {
|
|
929
|
-
seen.push(agentAddresses);
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
const ws = createMockWs();
|
|
933
|
-
router.handleOpen(ws);
|
|
934
|
-
router.handleMessage(
|
|
935
|
-
ws,
|
|
936
|
-
JSON.stringify({
|
|
937
|
-
type: "register",
|
|
938
|
-
sidecarId: "sc-1",
|
|
939
|
-
token: "tok",
|
|
940
|
-
agentAddresses: ["agent@local"],
|
|
941
|
-
}),
|
|
942
|
-
);
|
|
943
|
-
router.handleClose(ws);
|
|
944
|
-
|
|
945
|
-
expect(seen).toEqual([["agent@local"]]);
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
test("connector.state.changed populates the cache and is readable via getConnectorState", () => {
|
|
949
|
-
const router = createSidecarRouter({});
|
|
950
|
-
const ws = createMockWs();
|
|
951
|
-
router.handleOpen(ws);
|
|
952
|
-
router.handleMessage(
|
|
953
|
-
ws,
|
|
954
|
-
JSON.stringify({
|
|
955
|
-
type: "register",
|
|
956
|
-
sidecarId: "sc-1",
|
|
957
|
-
token: "tok",
|
|
958
|
-
agentAddresses: ["agent@local"],
|
|
959
|
-
}),
|
|
960
|
-
);
|
|
961
|
-
|
|
962
|
-
// Before any state frame, the cache is absent → null.
|
|
963
|
-
expect(router.getConnectorState("agent@local")).toBeNull();
|
|
964
|
-
|
|
965
|
-
const state = {
|
|
966
|
-
threadRoot: "<root@example.com>",
|
|
967
|
-
lastMessageId: "<last@example.com>",
|
|
968
|
-
replyTo: "user@example.com",
|
|
969
|
-
cc: [],
|
|
970
|
-
};
|
|
971
|
-
router.handleMessage(
|
|
972
|
-
ws,
|
|
973
|
-
JSON.stringify({
|
|
974
|
-
type: "connector.state.changed",
|
|
975
|
-
agentAddress: "agent@local",
|
|
976
|
-
connectorState: state,
|
|
977
|
-
}),
|
|
978
|
-
);
|
|
979
|
-
|
|
980
|
-
expect(router.getConnectorState("agent@local")).toEqual(state);
|
|
981
|
-
|
|
982
|
-
// An explicit-null frame clears the cached state.
|
|
983
|
-
router.handleMessage(
|
|
984
|
-
ws,
|
|
985
|
-
JSON.stringify({
|
|
986
|
-
type: "connector.state.changed",
|
|
987
|
-
agentAddress: "agent@local",
|
|
988
|
-
connectorState: null,
|
|
989
|
-
}),
|
|
990
|
-
);
|
|
991
|
-
|
|
992
|
-
expect(router.getConnectorState("agent@local")).toBeNull();
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
test("connector.state.changed is emitted on router.events", () => {
|
|
996
|
-
const router = createSidecarRouter({});
|
|
997
|
-
const seen: { addr: string; state: unknown }[] = [];
|
|
998
|
-
router.events.on(
|
|
999
|
-
"connector.state.changed",
|
|
1000
|
-
({ agentAddress, connectorState }) => {
|
|
1001
|
-
seen.push({ addr: agentAddress, state: connectorState });
|
|
1002
|
-
},
|
|
1003
|
-
);
|
|
1004
|
-
|
|
1005
|
-
const ws = createMockWs();
|
|
1006
|
-
router.handleOpen(ws);
|
|
1007
|
-
router.handleMessage(
|
|
1008
|
-
ws,
|
|
1009
|
-
JSON.stringify({
|
|
1010
|
-
type: "register",
|
|
1011
|
-
sidecarId: "sc-1",
|
|
1012
|
-
token: "tok",
|
|
1013
|
-
agentAddresses: ["agent@local"],
|
|
1014
|
-
}),
|
|
1015
|
-
);
|
|
1016
|
-
|
|
1017
|
-
const state = {
|
|
1018
|
-
threadRoot: "<root@example.com>",
|
|
1019
|
-
lastMessageId: "<last@example.com>",
|
|
1020
|
-
replyTo: "user@example.com",
|
|
1021
|
-
cc: [],
|
|
1022
|
-
subject: "Hi",
|
|
1023
|
-
};
|
|
1024
|
-
router.handleMessage(
|
|
1025
|
-
ws,
|
|
1026
|
-
JSON.stringify({
|
|
1027
|
-
type: "connector.state.changed",
|
|
1028
|
-
agentAddress: "agent@local",
|
|
1029
|
-
connectorState: state,
|
|
1030
|
-
}),
|
|
1031
|
-
);
|
|
1032
|
-
|
|
1033
|
-
expect(seen).toEqual([{ addr: "agent@local", state }]);
|
|
1034
|
-
});
|
|
1035
|
-
|
|
1036
|
-
test("live takeover via register evicts the prior owner's cached connector state", () => {
|
|
1037
|
-
const router = createSidecarRouter({});
|
|
1038
|
-
|
|
1039
|
-
// First sidecar registers and reports connector state.
|
|
1040
|
-
const ws1 = createMockWs();
|
|
1041
|
-
router.handleOpen(ws1);
|
|
1042
|
-
router.handleMessage(
|
|
1043
|
-
ws1,
|
|
1044
|
-
JSON.stringify({
|
|
1045
|
-
type: "register",
|
|
1046
|
-
sidecarId: "sc-1",
|
|
1047
|
-
token: "tok",
|
|
1048
|
-
agentAddresses: ["agent@local"],
|
|
1049
|
-
}),
|
|
1050
|
-
);
|
|
1051
|
-
router.handleMessage(
|
|
1052
|
-
ws1,
|
|
1053
|
-
JSON.stringify({
|
|
1054
|
-
type: "connector.state.changed",
|
|
1055
|
-
agentAddress: "agent@local",
|
|
1056
|
-
connectorState: {
|
|
1057
|
-
threadRoot: "<root@example.com>",
|
|
1058
|
-
lastMessageId: "<last@example.com>",
|
|
1059
|
-
replyTo: "user@example.com",
|
|
1060
|
-
cc: [],
|
|
1061
|
-
},
|
|
1062
|
-
}),
|
|
1063
|
-
);
|
|
1064
|
-
expect(router.getConnectorState("agent@local")).not.toBeNull();
|
|
1065
|
-
|
|
1066
|
-
// A second sidecar registers claiming the same address without
|
|
1067
|
-
// the first having disconnected. The prior cache must be evicted
|
|
1068
|
-
// so it cannot mis-thread mail in the window before the new
|
|
1069
|
-
// owner bootstraps.
|
|
1070
|
-
const ws2 = createMockWs();
|
|
1071
|
-
router.handleOpen(ws2);
|
|
1072
|
-
router.handleMessage(
|
|
1073
|
-
ws2,
|
|
1074
|
-
JSON.stringify({
|
|
1075
|
-
type: "register",
|
|
1076
|
-
sidecarId: "sc-2",
|
|
1077
|
-
token: "tok",
|
|
1078
|
-
agentAddresses: ["agent@local"],
|
|
1079
|
-
}),
|
|
1080
|
-
);
|
|
1081
|
-
|
|
1082
|
-
expect(router.getConnectorState("agent@local")).toBeNull();
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
test("disconnect clears cached connector state for affected agents", () => {
|
|
1086
|
-
const router = createSidecarRouter({});
|
|
1087
|
-
const ws = createMockWs();
|
|
1088
|
-
router.handleOpen(ws);
|
|
1089
|
-
router.handleMessage(
|
|
1090
|
-
ws,
|
|
1091
|
-
JSON.stringify({
|
|
1092
|
-
type: "register",
|
|
1093
|
-
sidecarId: "sc-1",
|
|
1094
|
-
token: "tok",
|
|
1095
|
-
agentAddresses: ["agent@local"],
|
|
1096
|
-
}),
|
|
1097
|
-
);
|
|
1098
|
-
|
|
1099
|
-
router.handleMessage(
|
|
1100
|
-
ws,
|
|
1101
|
-
JSON.stringify({
|
|
1102
|
-
type: "connector.state.changed",
|
|
1103
|
-
agentAddress: "agent@local",
|
|
1104
|
-
connectorState: {
|
|
1105
|
-
threadRoot: "<root@example.com>",
|
|
1106
|
-
lastMessageId: "<last@example.com>",
|
|
1107
|
-
replyTo: "user@example.com",
|
|
1108
|
-
cc: [],
|
|
1109
|
-
},
|
|
1110
|
-
}),
|
|
1111
|
-
);
|
|
1112
|
-
|
|
1113
|
-
expect(router.getConnectorState("agent@local")).not.toBeNull();
|
|
1114
|
-
|
|
1115
|
-
router.handleClose(ws);
|
|
1116
|
-
|
|
1117
|
-
expect(router.getConnectorState("agent@local")).toBeNull();
|
|
1118
|
-
});
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
describe("session subscriptions", () => {
|
|
1122
|
-
test("subscriber receives events for its session", () => {
|
|
1123
|
-
const received: unknown[] = [];
|
|
1124
|
-
const ws = createMockWs();
|
|
1125
|
-
router.handleOpen(ws);
|
|
1126
|
-
router.handleMessage(
|
|
1127
|
-
ws,
|
|
1128
|
-
JSON.stringify({
|
|
1129
|
-
type: "register",
|
|
1130
|
-
sidecarId: "sc-1",
|
|
1131
|
-
token: "tok",
|
|
1132
|
-
agentAddresses: [],
|
|
1133
|
-
}),
|
|
1134
|
-
);
|
|
1135
|
-
|
|
1136
|
-
router.subscribeAgent("agent@local", (event) => received.push(event));
|
|
1137
|
-
|
|
1138
|
-
router.handleMessage(
|
|
1139
|
-
ws,
|
|
1140
|
-
JSON.stringify({
|
|
1141
|
-
type: "agent.event",
|
|
1142
|
-
agentAddress: "agent@local",
|
|
1143
|
-
sessionId: "sess-1",
|
|
1144
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1145
|
-
}),
|
|
1146
|
-
);
|
|
1147
|
-
|
|
1148
|
-
expect(received).toHaveLength(1);
|
|
1149
|
-
expect(received[0]).toEqual({ type: "reactor.start", seq: 0, data: {} });
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
test("subscriber does not receive events for other agents", () => {
|
|
1153
|
-
const received: unknown[] = [];
|
|
1154
|
-
const ws = createMockWs();
|
|
1155
|
-
router.handleOpen(ws);
|
|
1156
|
-
router.handleMessage(
|
|
1157
|
-
ws,
|
|
1158
|
-
JSON.stringify({
|
|
1159
|
-
type: "register",
|
|
1160
|
-
sidecarId: "sc-1",
|
|
1161
|
-
token: "tok",
|
|
1162
|
-
agentAddresses: [],
|
|
1163
|
-
}),
|
|
1164
|
-
);
|
|
1165
|
-
|
|
1166
|
-
router.subscribeAgent("agent@local", (event) => received.push(event));
|
|
1167
|
-
|
|
1168
|
-
router.handleMessage(
|
|
1169
|
-
ws,
|
|
1170
|
-
JSON.stringify({
|
|
1171
|
-
type: "agent.event",
|
|
1172
|
-
agentAddress: "other-agent@local",
|
|
1173
|
-
sessionId: "sess-2",
|
|
1174
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1175
|
-
}),
|
|
1176
|
-
);
|
|
1177
|
-
|
|
1178
|
-
expect(received).toHaveLength(0);
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
test("unsubscribe stops delivery", () => {
|
|
1182
|
-
const received: unknown[] = [];
|
|
1183
|
-
const ws = createMockWs();
|
|
1184
|
-
router.handleOpen(ws);
|
|
1185
|
-
router.handleMessage(
|
|
1186
|
-
ws,
|
|
1187
|
-
JSON.stringify({
|
|
1188
|
-
type: "register",
|
|
1189
|
-
sidecarId: "sc-1",
|
|
1190
|
-
token: "tok",
|
|
1191
|
-
agentAddresses: [],
|
|
1192
|
-
}),
|
|
1193
|
-
);
|
|
1194
|
-
|
|
1195
|
-
const unsub = router.subscribeAgent("agent@local", (event) =>
|
|
1196
|
-
received.push(event),
|
|
1197
|
-
);
|
|
1198
|
-
|
|
1199
|
-
router.handleMessage(
|
|
1200
|
-
ws,
|
|
1201
|
-
JSON.stringify({
|
|
1202
|
-
type: "agent.event",
|
|
1203
|
-
agentAddress: "agent@local",
|
|
1204
|
-
sessionId: "sess-1",
|
|
1205
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1206
|
-
}),
|
|
1207
|
-
);
|
|
1208
|
-
|
|
1209
|
-
unsub();
|
|
1210
|
-
|
|
1211
|
-
router.handleMessage(
|
|
1212
|
-
ws,
|
|
1213
|
-
JSON.stringify({
|
|
1214
|
-
type: "agent.event",
|
|
1215
|
-
agentAddress: "agent@local",
|
|
1216
|
-
sessionId: "sess-1",
|
|
1217
|
-
event: { type: "reactor.end", seq: 1, data: {} },
|
|
1218
|
-
}),
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
expect(received).toHaveLength(1);
|
|
1222
|
-
expect(received[0]).toEqual({ type: "reactor.start", seq: 0, data: {} });
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
test("multiple subscribers receive the same event", () => {
|
|
1226
|
-
const received1: unknown[] = [];
|
|
1227
|
-
const received2: unknown[] = [];
|
|
1228
|
-
const ws = createMockWs();
|
|
1229
|
-
router.handleOpen(ws);
|
|
1230
|
-
router.handleMessage(
|
|
1231
|
-
ws,
|
|
1232
|
-
JSON.stringify({
|
|
1233
|
-
type: "register",
|
|
1234
|
-
sidecarId: "sc-1",
|
|
1235
|
-
token: "tok",
|
|
1236
|
-
agentAddresses: [],
|
|
1237
|
-
}),
|
|
1238
|
-
);
|
|
1239
|
-
|
|
1240
|
-
router.subscribeAgent("agent@local", (event) => received1.push(event));
|
|
1241
|
-
router.subscribeAgent("agent@local", (event) => received2.push(event));
|
|
1242
|
-
|
|
1243
|
-
router.handleMessage(
|
|
1244
|
-
ws,
|
|
1245
|
-
JSON.stringify({
|
|
1246
|
-
type: "agent.event",
|
|
1247
|
-
agentAddress: "agent@local",
|
|
1248
|
-
sessionId: "sess-1",
|
|
1249
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1250
|
-
}),
|
|
1251
|
-
);
|
|
1252
|
-
|
|
1253
|
-
expect(received1).toHaveLength(1);
|
|
1254
|
-
expect(received2).toHaveLength(1);
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
test("stale unsubscribe does not evict a later subscriber", () => {
|
|
1258
|
-
const ws = createMockWs();
|
|
1259
|
-
router.handleOpen(ws);
|
|
1260
|
-
router.handleMessage(
|
|
1261
|
-
ws,
|
|
1262
|
-
JSON.stringify({
|
|
1263
|
-
type: "register",
|
|
1264
|
-
sidecarId: "sc-1",
|
|
1265
|
-
token: "tok",
|
|
1266
|
-
agentAddresses: [],
|
|
1267
|
-
}),
|
|
1268
|
-
);
|
|
1269
|
-
|
|
1270
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1271
|
-
const unsub = router.subscribeAgent("agent@local", () => {});
|
|
1272
|
-
unsub();
|
|
1273
|
-
|
|
1274
|
-
const received: unknown[] = [];
|
|
1275
|
-
router.subscribeAgent("agent@local", (event) => received.push(event));
|
|
1276
|
-
|
|
1277
|
-
// Double-unsubscribe with the stale closure
|
|
1278
|
-
unsub();
|
|
1279
|
-
|
|
1280
|
-
router.handleMessage(
|
|
1281
|
-
ws,
|
|
1282
|
-
JSON.stringify({
|
|
1283
|
-
type: "agent.event",
|
|
1284
|
-
agentAddress: "agent@local",
|
|
1285
|
-
sessionId: "sess-1",
|
|
1286
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1287
|
-
}),
|
|
1288
|
-
);
|
|
1289
|
-
|
|
1290
|
-
expect(received).toHaveLength(1);
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
test("subscriber that unsubscribes mid-dispatch does not drop later subscribers", () => {
|
|
1294
|
-
const received: unknown[] = [];
|
|
1295
|
-
const ws = createMockWs();
|
|
1296
|
-
router.handleOpen(ws);
|
|
1297
|
-
router.handleMessage(
|
|
1298
|
-
ws,
|
|
1299
|
-
JSON.stringify({
|
|
1300
|
-
type: "register",
|
|
1301
|
-
sidecarId: "sc-1",
|
|
1302
|
-
token: "tok",
|
|
1303
|
-
agentAddresses: [],
|
|
1304
|
-
}),
|
|
1305
|
-
);
|
|
1306
|
-
|
|
1307
|
-
const unsub1Ref: { current: (() => void) | null } = { current: null };
|
|
1308
|
-
unsub1Ref.current = router.subscribeAgent("agent@local", () => {
|
|
1309
|
-
unsub1Ref.current?.();
|
|
1310
|
-
});
|
|
1311
|
-
router.subscribeAgent("agent@local", (event) => received.push(event));
|
|
1312
|
-
|
|
1313
|
-
router.handleMessage(
|
|
1314
|
-
ws,
|
|
1315
|
-
JSON.stringify({
|
|
1316
|
-
type: "agent.event",
|
|
1317
|
-
agentAddress: "agent@local",
|
|
1318
|
-
sessionId: "sess-1",
|
|
1319
|
-
event: { type: "reactor.start", seq: 0, data: {} },
|
|
1320
|
-
}),
|
|
1321
|
-
);
|
|
1322
|
-
|
|
1323
|
-
expect(received).toHaveLength(1);
|
|
1324
|
-
expect(received[0]).toEqual({ type: "reactor.start", seq: 0, data: {} });
|
|
1325
|
-
});
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
describe("challenge/response reconnect", () => {
|
|
1329
|
-
test("reconnect issues challenge and verifies signature", async () => {
|
|
1330
|
-
const kp = await generateKeyPair();
|
|
1331
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1332
|
-
|
|
1333
|
-
const router = createSidecarRouter({
|
|
1334
|
-
requestTimeoutMs: 5000,
|
|
1335
|
-
lookups: {
|
|
1336
|
-
lookupPublicKey: async (addr) =>
|
|
1337
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1338
|
-
},
|
|
1339
|
-
});
|
|
1340
|
-
|
|
1341
|
-
const ws = createMockWs();
|
|
1342
|
-
router.handleOpen(ws);
|
|
1343
|
-
router.handleMessage(
|
|
1344
|
-
ws,
|
|
1345
|
-
JSON.stringify({
|
|
1346
|
-
type: "reconnect",
|
|
1347
|
-
sidecarId: "sc-1",
|
|
1348
|
-
token: "tok",
|
|
1349
|
-
agentAddresses: ["agent@local"],
|
|
1350
|
-
}),
|
|
1351
|
-
);
|
|
1352
|
-
|
|
1353
|
-
// Wait for async handleReconnect to complete.
|
|
1354
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1355
|
-
|
|
1356
|
-
// Should have received a challenge frame.
|
|
1357
|
-
const challengeFrame = ws.sent
|
|
1358
|
-
.map((s) => JSON.parse(s))
|
|
1359
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1360
|
-
expect(challengeFrame).toBeDefined();
|
|
1361
|
-
expect(challengeFrame.challenges).toHaveLength(1);
|
|
1362
|
-
|
|
1363
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1364
|
-
expect(address).toBe("agent@local");
|
|
1365
|
-
|
|
1366
|
-
// Sign and respond.
|
|
1367
|
-
const signature = signChallenge(nonce, address, kp.privateKey);
|
|
1368
|
-
router.handleMessage(
|
|
1369
|
-
ws,
|
|
1370
|
-
JSON.stringify({
|
|
1371
|
-
type: "challenge.response",
|
|
1372
|
-
responses: [{ address, signature }],
|
|
1373
|
-
}),
|
|
1374
|
-
);
|
|
1375
|
-
|
|
1376
|
-
expect(router.getRoutableAddresses()).toContain("agent@local");
|
|
1377
|
-
});
|
|
1378
|
-
|
|
1379
|
-
test("reconnect rejects invalid signature", async () => {
|
|
1380
|
-
const kp = await generateKeyPair();
|
|
1381
|
-
const wrongKp = await generateKeyPair();
|
|
1382
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1383
|
-
|
|
1384
|
-
const router = createSidecarRouter({
|
|
1385
|
-
requestTimeoutMs: 5000,
|
|
1386
|
-
lookups: {
|
|
1387
|
-
lookupPublicKey: async (addr) =>
|
|
1388
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1389
|
-
},
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
const ws = createMockWs();
|
|
1393
|
-
router.handleOpen(ws);
|
|
1394
|
-
router.handleMessage(
|
|
1395
|
-
ws,
|
|
1396
|
-
JSON.stringify({
|
|
1397
|
-
type: "reconnect",
|
|
1398
|
-
sidecarId: "sc-1",
|
|
1399
|
-
token: "tok",
|
|
1400
|
-
agentAddresses: ["agent@local"],
|
|
1401
|
-
}),
|
|
1402
|
-
);
|
|
1403
|
-
|
|
1404
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1405
|
-
|
|
1406
|
-
const challengeFrame = ws.sent
|
|
1407
|
-
.map((s) => JSON.parse(s))
|
|
1408
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1409
|
-
|
|
1410
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1411
|
-
|
|
1412
|
-
// Sign with wrong key.
|
|
1413
|
-
const badSig = signChallenge(nonce, address, wrongKp.privateKey);
|
|
1414
|
-
router.handleMessage(
|
|
1415
|
-
ws,
|
|
1416
|
-
JSON.stringify({
|
|
1417
|
-
type: "challenge.response",
|
|
1418
|
-
responses: [{ address, signature: badSig }],
|
|
1419
|
-
}),
|
|
1420
|
-
);
|
|
1421
|
-
|
|
1422
|
-
expect(router.getRoutableAddresses()).not.toContain("agent@local");
|
|
1423
|
-
|
|
1424
|
-
const failedFrame = ws.sent
|
|
1425
|
-
.map((s) => JSON.parse(s))
|
|
1426
|
-
.find((f: { type: string }) => f.type === "challenge.failed");
|
|
1427
|
-
expect(failedFrame).toBeDefined();
|
|
1428
|
-
expect(failedFrame.address).toBe("agent@local");
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
test("reconnect sends challenge.failed for unknown address", async () => {
|
|
1432
|
-
const router = createSidecarRouter({
|
|
1433
|
-
requestTimeoutMs: 5000,
|
|
1434
|
-
lookups: {
|
|
1435
|
-
lookupPublicKey: async () => null,
|
|
1436
|
-
},
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
const ws = createMockWs();
|
|
1440
|
-
router.handleOpen(ws);
|
|
1441
|
-
router.handleMessage(
|
|
1442
|
-
ws,
|
|
1443
|
-
JSON.stringify({
|
|
1444
|
-
type: "reconnect",
|
|
1445
|
-
sidecarId: "sc-1",
|
|
1446
|
-
token: "tok",
|
|
1447
|
-
agentAddresses: ["unknown@local"],
|
|
1448
|
-
}),
|
|
1449
|
-
);
|
|
1450
|
-
|
|
1451
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1452
|
-
|
|
1453
|
-
const failedFrame = ws.sent
|
|
1454
|
-
.map((s) => JSON.parse(s))
|
|
1455
|
-
.find((f: { type: string }) => f.type === "challenge.failed");
|
|
1456
|
-
expect(failedFrame).toBeDefined();
|
|
1457
|
-
expect(failedFrame.address).toBe("unknown@local");
|
|
1458
|
-
expect(failedFrame.reason).toBe("Unknown agent address");
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
test("partial success routes verified addresses only", async () => {
|
|
1462
|
-
const kp1 = await generateKeyPair();
|
|
1463
|
-
const kp2 = await generateKeyPair();
|
|
1464
|
-
const wrongKp = await generateKeyPair();
|
|
1465
|
-
|
|
1466
|
-
const keys = new Map([
|
|
1467
|
-
["agent-a@local", hexEncode(kp1.publicKey)],
|
|
1468
|
-
["agent-b@local", hexEncode(kp2.publicKey)],
|
|
1469
|
-
]);
|
|
1470
|
-
|
|
1471
|
-
const router = createSidecarRouter({
|
|
1472
|
-
requestTimeoutMs: 5000,
|
|
1473
|
-
lookups: {
|
|
1474
|
-
lookupPublicKey: async (addr) => keys.get(addr) ?? null,
|
|
1475
|
-
},
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
const ws = createMockWs();
|
|
1479
|
-
router.handleOpen(ws);
|
|
1480
|
-
router.handleMessage(
|
|
1481
|
-
ws,
|
|
1482
|
-
JSON.stringify({
|
|
1483
|
-
type: "reconnect",
|
|
1484
|
-
sidecarId: "sc-1",
|
|
1485
|
-
token: "tok",
|
|
1486
|
-
agentAddresses: ["agent-a@local", "agent-b@local"],
|
|
1487
|
-
}),
|
|
1488
|
-
);
|
|
1489
|
-
|
|
1490
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1491
|
-
|
|
1492
|
-
const challengeFrame = ws.sent
|
|
1493
|
-
.map((s) => JSON.parse(s))
|
|
1494
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1495
|
-
expect(challengeFrame.challenges).toHaveLength(2);
|
|
1496
|
-
|
|
1497
|
-
const responses = challengeFrame.challenges.map(
|
|
1498
|
-
(c: { address: string; nonce: string }) => {
|
|
1499
|
-
const key =
|
|
1500
|
-
c.address === "agent-a@local" ? kp1.privateKey : wrongKp.privateKey;
|
|
1501
|
-
return {
|
|
1502
|
-
address: c.address,
|
|
1503
|
-
signature: signChallenge(c.nonce, c.address, key),
|
|
1504
|
-
};
|
|
1505
|
-
},
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
router.handleMessage(
|
|
1509
|
-
ws,
|
|
1510
|
-
JSON.stringify({ type: "challenge.response", responses }),
|
|
1511
|
-
);
|
|
1512
|
-
|
|
1513
|
-
expect(router.getRoutableAddresses()).toContain("agent-a@local");
|
|
1514
|
-
expect(router.getRoutableAddresses()).not.toContain("agent-b@local");
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
|
-
test("reconnect with stale deployRef emits deploy.ref.stale", async () => {
|
|
1518
|
-
const kp = await generateKeyPair();
|
|
1519
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1520
|
-
const staleAddresses: string[] = [];
|
|
1521
|
-
|
|
1522
|
-
const router = createSidecarRouter({
|
|
1523
|
-
requestTimeoutMs: 5000,
|
|
1524
|
-
lookups: {
|
|
1525
|
-
lookupPublicKey: async (addr) =>
|
|
1526
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1527
|
-
lookupDeployRef: async () => "aaaa",
|
|
1528
|
-
},
|
|
1529
|
-
});
|
|
1530
|
-
router.events.on("deploy.ref.stale", ({ agentAddress }) => {
|
|
1531
|
-
staleAddresses.push(agentAddress);
|
|
1532
|
-
});
|
|
1533
|
-
|
|
1534
|
-
const ws = createMockWs();
|
|
1535
|
-
router.handleOpen(ws);
|
|
1536
|
-
router.handleMessage(
|
|
1537
|
-
ws,
|
|
1538
|
-
JSON.stringify({
|
|
1539
|
-
type: "reconnect",
|
|
1540
|
-
sidecarId: "sc-1",
|
|
1541
|
-
token: "tok",
|
|
1542
|
-
agentAddresses: ["agent@local"],
|
|
1543
|
-
deployRefs: { "agent@local": "bbbb" },
|
|
1544
|
-
}),
|
|
1545
|
-
);
|
|
1546
|
-
|
|
1547
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1548
|
-
|
|
1549
|
-
const challengeFrame = ws.sent
|
|
1550
|
-
.map((s) => JSON.parse(s))
|
|
1551
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1552
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1553
|
-
router.handleMessage(
|
|
1554
|
-
ws,
|
|
1555
|
-
JSON.stringify({
|
|
1556
|
-
type: "challenge.response",
|
|
1557
|
-
responses: [
|
|
1558
|
-
{
|
|
1559
|
-
address,
|
|
1560
|
-
signature: signChallenge(nonce, address, kp.privateKey),
|
|
1561
|
-
},
|
|
1562
|
-
],
|
|
1563
|
-
}),
|
|
1564
|
-
);
|
|
1565
|
-
|
|
1566
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1567
|
-
expect(staleAddresses).toContain("agent@local");
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
test("reconnect with matching deployRef skips deploy.ref.stale", async () => {
|
|
1571
|
-
const kp = await generateKeyPair();
|
|
1572
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1573
|
-
const staleAddresses: string[] = [];
|
|
1574
|
-
|
|
1575
|
-
const router = createSidecarRouter({
|
|
1576
|
-
requestTimeoutMs: 5000,
|
|
1577
|
-
lookups: {
|
|
1578
|
-
lookupPublicKey: async (addr) =>
|
|
1579
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1580
|
-
lookupDeployRef: async () => "aaaa",
|
|
1581
|
-
},
|
|
1582
|
-
});
|
|
1583
|
-
router.events.on("deploy.ref.stale", ({ agentAddress }) => {
|
|
1584
|
-
staleAddresses.push(agentAddress);
|
|
1585
|
-
});
|
|
1586
|
-
|
|
1587
|
-
const ws = createMockWs();
|
|
1588
|
-
router.handleOpen(ws);
|
|
1589
|
-
router.handleMessage(
|
|
1590
|
-
ws,
|
|
1591
|
-
JSON.stringify({
|
|
1592
|
-
type: "reconnect",
|
|
1593
|
-
sidecarId: "sc-1",
|
|
1594
|
-
token: "tok",
|
|
1595
|
-
agentAddresses: ["agent@local"],
|
|
1596
|
-
deployRefs: { "agent@local": "aaaa" },
|
|
1597
|
-
}),
|
|
1598
|
-
);
|
|
1599
|
-
|
|
1600
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1601
|
-
|
|
1602
|
-
const challengeFrame = ws.sent
|
|
1603
|
-
.map((s) => JSON.parse(s))
|
|
1604
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1605
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1606
|
-
router.handleMessage(
|
|
1607
|
-
ws,
|
|
1608
|
-
JSON.stringify({
|
|
1609
|
-
type: "challenge.response",
|
|
1610
|
-
responses: [
|
|
1611
|
-
{
|
|
1612
|
-
address,
|
|
1613
|
-
signature: signChallenge(nonce, address, kp.privateKey),
|
|
1614
|
-
},
|
|
1615
|
-
],
|
|
1616
|
-
}),
|
|
1617
|
-
);
|
|
1618
|
-
|
|
1619
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1620
|
-
expect(staleAddresses).toEqual([]);
|
|
1621
|
-
});
|
|
1622
|
-
|
|
1623
|
-
test("reconnect with absent deployRef emits deploy.ref.stale", async () => {
|
|
1624
|
-
const kp = await generateKeyPair();
|
|
1625
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1626
|
-
const staleAddresses: string[] = [];
|
|
1627
|
-
|
|
1628
|
-
const router = createSidecarRouter({
|
|
1629
|
-
requestTimeoutMs: 5000,
|
|
1630
|
-
lookups: {
|
|
1631
|
-
lookupPublicKey: async (addr) =>
|
|
1632
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1633
|
-
lookupDeployRef: async () => "aaaa",
|
|
1634
|
-
},
|
|
1635
|
-
});
|
|
1636
|
-
router.events.on("deploy.ref.stale", ({ agentAddress }) => {
|
|
1637
|
-
staleAddresses.push(agentAddress);
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
const ws = createMockWs();
|
|
1641
|
-
router.handleOpen(ws);
|
|
1642
|
-
router.handleMessage(
|
|
1643
|
-
ws,
|
|
1644
|
-
JSON.stringify({
|
|
1645
|
-
type: "reconnect",
|
|
1646
|
-
sidecarId: "sc-1",
|
|
1647
|
-
token: "tok",
|
|
1648
|
-
agentAddresses: ["agent@local"],
|
|
1649
|
-
}),
|
|
1650
|
-
);
|
|
1651
|
-
|
|
1652
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1653
|
-
|
|
1654
|
-
const challengeFrame = ws.sent
|
|
1655
|
-
.map((s) => JSON.parse(s))
|
|
1656
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1657
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1658
|
-
router.handleMessage(
|
|
1659
|
-
ws,
|
|
1660
|
-
JSON.stringify({
|
|
1661
|
-
type: "challenge.response",
|
|
1662
|
-
responses: [
|
|
1663
|
-
{
|
|
1664
|
-
address,
|
|
1665
|
-
signature: signChallenge(nonce, address, kp.privateKey),
|
|
1666
|
-
},
|
|
1667
|
-
],
|
|
1668
|
-
}),
|
|
1669
|
-
);
|
|
1670
|
-
|
|
1671
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1672
|
-
expect(staleAddresses).toContain("agent@local");
|
|
1673
|
-
});
|
|
1674
|
-
|
|
1675
|
-
test("reconnect skips re-deploy when hub has no deploy ref", async () => {
|
|
1676
|
-
const kp = await generateKeyPair();
|
|
1677
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
1678
|
-
const staleAddresses: string[] = [];
|
|
1679
|
-
|
|
1680
|
-
const router = createSidecarRouter({
|
|
1681
|
-
requestTimeoutMs: 5000,
|
|
1682
|
-
lookups: {
|
|
1683
|
-
lookupPublicKey: async (addr) =>
|
|
1684
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
1685
|
-
lookupDeployRef: async () => null,
|
|
1686
|
-
},
|
|
1687
|
-
});
|
|
1688
|
-
|
|
1689
|
-
const ws = createMockWs();
|
|
1690
|
-
router.handleOpen(ws);
|
|
1691
|
-
router.handleMessage(
|
|
1692
|
-
ws,
|
|
1693
|
-
JSON.stringify({
|
|
1694
|
-
type: "reconnect",
|
|
1695
|
-
sidecarId: "sc-1",
|
|
1696
|
-
token: "tok",
|
|
1697
|
-
agentAddresses: ["agent@local"],
|
|
1698
|
-
deployRefs: { "agent@local": "bbbb" },
|
|
1699
|
-
}),
|
|
1700
|
-
);
|
|
1701
|
-
|
|
1702
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1703
|
-
|
|
1704
|
-
const challengeFrame = ws.sent
|
|
1705
|
-
.map((s) => JSON.parse(s))
|
|
1706
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1707
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
1708
|
-
router.handleMessage(
|
|
1709
|
-
ws,
|
|
1710
|
-
JSON.stringify({
|
|
1711
|
-
type: "challenge.response",
|
|
1712
|
-
responses: [
|
|
1713
|
-
{
|
|
1714
|
-
address,
|
|
1715
|
-
signature: signChallenge(nonce, address, kp.privateKey),
|
|
1716
|
-
},
|
|
1717
|
-
],
|
|
1718
|
-
}),
|
|
1719
|
-
);
|
|
1720
|
-
|
|
1721
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1722
|
-
expect(staleAddresses).toEqual([]);
|
|
1723
|
-
});
|
|
1724
|
-
|
|
1725
|
-
test("disconnect cleans up pending challenge", async () => {
|
|
1726
|
-
const kp = await generateKeyPair();
|
|
1727
|
-
|
|
1728
|
-
const router = createSidecarRouter({
|
|
1729
|
-
requestTimeoutMs: 5000,
|
|
1730
|
-
lookups: {
|
|
1731
|
-
lookupPublicKey: async (addr) =>
|
|
1732
|
-
addr === "agent@local" ? hexEncode(kp.publicKey) : null,
|
|
1733
|
-
},
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
const ws = createMockWs();
|
|
1737
|
-
router.handleOpen(ws);
|
|
1738
|
-
router.handleMessage(
|
|
1739
|
-
ws,
|
|
1740
|
-
JSON.stringify({
|
|
1741
|
-
type: "reconnect",
|
|
1742
|
-
sidecarId: "sc-1",
|
|
1743
|
-
token: "tok",
|
|
1744
|
-
agentAddresses: ["agent@local"],
|
|
1745
|
-
}),
|
|
1746
|
-
);
|
|
1747
|
-
|
|
1748
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1749
|
-
|
|
1750
|
-
// Disconnect before responding.
|
|
1751
|
-
router.handleClose(ws);
|
|
1752
|
-
|
|
1753
|
-
expect(router.getConnectedSidecars()).toEqual([]);
|
|
1754
|
-
expect(router.getRoutableAddresses()).toEqual([]);
|
|
1755
|
-
});
|
|
1756
|
-
});
|
|
1757
|
-
|
|
1758
|
-
describe("disconnect message queuing", () => {
|
|
1759
|
-
test("mail queued during disconnect is flushed on reconnect", async () => {
|
|
1760
|
-
const kp = await generateKeyPair();
|
|
1761
|
-
const router = createSidecarRouter({
|
|
1762
|
-
requestTimeoutMs: 500,
|
|
1763
|
-
lookups: {
|
|
1764
|
-
async lookupPublicKey() {
|
|
1765
|
-
return hexEncode(kp.publicKey);
|
|
1766
|
-
},
|
|
1767
|
-
},
|
|
1768
|
-
});
|
|
1769
|
-
|
|
1770
|
-
// Initial connection with one agent.
|
|
1771
|
-
const ws1 = createMockWs();
|
|
1772
|
-
router.handleOpen(ws1);
|
|
1773
|
-
router.handleMessage(
|
|
1774
|
-
ws1,
|
|
1775
|
-
JSON.stringify({
|
|
1776
|
-
type: "register",
|
|
1777
|
-
sidecarId: "sc-1",
|
|
1778
|
-
token: "tok",
|
|
1779
|
-
agentAddresses: ["agent@local"],
|
|
1780
|
-
}),
|
|
1781
|
-
);
|
|
1782
|
-
|
|
1783
|
-
// Disconnect — creates a queue entry.
|
|
1784
|
-
router.handleClose(ws1);
|
|
1785
|
-
|
|
1786
|
-
// Send mail while disconnected.
|
|
1787
|
-
const queued = router.routeMail("agent@local", "queued-message");
|
|
1788
|
-
expect(queued).toBe(true);
|
|
1789
|
-
|
|
1790
|
-
// Reconnect with challenge/response.
|
|
1791
|
-
const ws2 = createMockWs();
|
|
1792
|
-
router.handleOpen(ws2);
|
|
1793
|
-
router.handleMessage(
|
|
1794
|
-
ws2,
|
|
1795
|
-
JSON.stringify({
|
|
1796
|
-
type: "reconnect",
|
|
1797
|
-
sidecarId: "sc-1",
|
|
1798
|
-
token: "tok",
|
|
1799
|
-
agentAddresses: ["agent@local"],
|
|
1800
|
-
}),
|
|
1801
|
-
);
|
|
1802
|
-
|
|
1803
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1804
|
-
|
|
1805
|
-
const challengeFrame = ws2.sent
|
|
1806
|
-
.map((s) => JSON.parse(s))
|
|
1807
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1808
|
-
|
|
1809
|
-
const responses = challengeFrame.challenges.map(
|
|
1810
|
-
(c: { address: string; nonce: string }) => ({
|
|
1811
|
-
address: c.address,
|
|
1812
|
-
signature: signChallenge(c.nonce, c.address, kp.privateKey),
|
|
1813
|
-
}),
|
|
1814
|
-
);
|
|
1815
|
-
|
|
1816
|
-
router.handleMessage(
|
|
1817
|
-
ws2,
|
|
1818
|
-
JSON.stringify({ type: "challenge.response", responses }),
|
|
1819
|
-
);
|
|
1820
|
-
|
|
1821
|
-
// The queued mail should have been flushed to the new connection.
|
|
1822
|
-
const flushed = ws2.sent
|
|
1823
|
-
.map((s) => JSON.parse(s))
|
|
1824
|
-
.filter((f: { type: string }) => f.type === "mail.inbound");
|
|
1825
|
-
expect(flushed).toHaveLength(1);
|
|
1826
|
-
expect(flushed[0].rawMessage).toBe("queued-message");
|
|
1827
|
-
});
|
|
1828
|
-
|
|
1829
|
-
test("mail to unknown address returns false", () => {
|
|
1830
|
-
expect(router.routeMail("unknown@local", "msg")).toBe(false);
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
test("queue evicts oldest when full", async () => {
|
|
1834
|
-
const kp = await generateKeyPair();
|
|
1835
|
-
const router = createSidecarRouter({
|
|
1836
|
-
requestTimeoutMs: 500,
|
|
1837
|
-
disconnectQueueMaxSize: 2,
|
|
1838
|
-
lookups: {
|
|
1839
|
-
async lookupPublicKey() {
|
|
1840
|
-
return hexEncode(kp.publicKey);
|
|
1841
|
-
},
|
|
1842
|
-
},
|
|
1843
|
-
});
|
|
1844
|
-
|
|
1845
|
-
const ws = createMockWs();
|
|
1846
|
-
router.handleOpen(ws);
|
|
1847
|
-
router.handleMessage(
|
|
1848
|
-
ws,
|
|
1849
|
-
JSON.stringify({
|
|
1850
|
-
type: "register",
|
|
1851
|
-
sidecarId: "sc-1",
|
|
1852
|
-
token: "tok",
|
|
1853
|
-
agentAddresses: ["agent@local"],
|
|
1854
|
-
}),
|
|
1855
|
-
);
|
|
1856
|
-
router.handleClose(ws);
|
|
1857
|
-
|
|
1858
|
-
// Queue 3 messages with max size 2 — oldest should be evicted.
|
|
1859
|
-
router.routeMail("agent@local", "msg-0");
|
|
1860
|
-
router.routeMail("agent@local", "msg-1");
|
|
1861
|
-
router.routeMail("agent@local", "msg-2");
|
|
1862
|
-
|
|
1863
|
-
// Reconnect with challenge/response to flush.
|
|
1864
|
-
const ws2 = createMockWs();
|
|
1865
|
-
router.handleOpen(ws2);
|
|
1866
|
-
router.handleMessage(
|
|
1867
|
-
ws2,
|
|
1868
|
-
JSON.stringify({
|
|
1869
|
-
type: "reconnect",
|
|
1870
|
-
sidecarId: "sc-1",
|
|
1871
|
-
token: "tok",
|
|
1872
|
-
agentAddresses: ["agent@local"],
|
|
1873
|
-
}),
|
|
1874
|
-
);
|
|
1875
|
-
|
|
1876
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1877
|
-
|
|
1878
|
-
const challengeFrame = ws2.sent
|
|
1879
|
-
.map((s) => JSON.parse(s))
|
|
1880
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
1881
|
-
|
|
1882
|
-
const responses = challengeFrame.challenges.map(
|
|
1883
|
-
(c: { address: string; nonce: string }) => ({
|
|
1884
|
-
address: c.address,
|
|
1885
|
-
signature: signChallenge(c.nonce, c.address, kp.privateKey),
|
|
1886
|
-
}),
|
|
1887
|
-
);
|
|
1888
|
-
|
|
1889
|
-
router.handleMessage(
|
|
1890
|
-
ws2,
|
|
1891
|
-
JSON.stringify({ type: "challenge.response", responses }),
|
|
1892
|
-
);
|
|
1893
|
-
|
|
1894
|
-
const flushed = ws2.sent
|
|
1895
|
-
.map((s) => JSON.parse(s))
|
|
1896
|
-
.filter((f: { type: string }) => f.type === "mail.inbound");
|
|
1897
|
-
|
|
1898
|
-
// Only the 2 newest messages should have been flushed.
|
|
1899
|
-
expect(flushed).toHaveLength(2);
|
|
1900
|
-
expect(flushed[0].rawMessage).toBe("msg-1");
|
|
1901
|
-
expect(flushed[1].rawMessage).toBe("msg-2");
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
test("sendSessionAbort rejects when agent is disconnected", async () => {
|
|
1905
|
-
const ws = createMockWs();
|
|
1906
|
-
router.handleOpen(ws);
|
|
1907
|
-
router.handleMessage(
|
|
1908
|
-
ws,
|
|
1909
|
-
JSON.stringify({
|
|
1910
|
-
type: "register",
|
|
1911
|
-
sidecarId: "sc-1",
|
|
1912
|
-
token: "tok",
|
|
1913
|
-
agentAddresses: ["agent@local"],
|
|
1914
|
-
}),
|
|
1915
|
-
);
|
|
1916
|
-
router.handleClose(ws);
|
|
1917
|
-
|
|
1918
|
-
await expect(
|
|
1919
|
-
router.sendSessionAbort("agent@local", "user_disconnect"),
|
|
1920
|
-
).rejects.toThrow("No sidecar connected");
|
|
1921
|
-
});
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
describe("ping/pong keepalive", () => {
|
|
1925
|
-
test("hub responds to ping with pong", () => {
|
|
1926
|
-
const ws = createMockWs();
|
|
1927
|
-
router.handleOpen(ws);
|
|
1928
|
-
router.handleMessage(
|
|
1929
|
-
ws,
|
|
1930
|
-
JSON.stringify({
|
|
1931
|
-
type: "register",
|
|
1932
|
-
sidecarId: "sc-1",
|
|
1933
|
-
token: "tok",
|
|
1934
|
-
agentAddresses: [],
|
|
1935
|
-
}),
|
|
1936
|
-
);
|
|
1937
|
-
|
|
1938
|
-
router.handleMessage(ws, JSON.stringify({ type: "ping" }));
|
|
1939
|
-
|
|
1940
|
-
const pong = ws.sent
|
|
1941
|
-
.map((s) => JSON.parse(s))
|
|
1942
|
-
.find((f: { type: string }) => f.type === "pong");
|
|
1943
|
-
expect(pong).toEqual({ type: "pong" });
|
|
1944
|
-
});
|
|
1945
|
-
|
|
1946
|
-
test("connection closed after ping timeout", async () => {
|
|
1947
|
-
const router = createSidecarRouter({
|
|
1948
|
-
requestTimeoutMs: 500,
|
|
1949
|
-
pingTimeoutMs: 100,
|
|
1950
|
-
});
|
|
1951
|
-
|
|
1952
|
-
const ws = createMockWs();
|
|
1953
|
-
router.handleOpen(ws);
|
|
1954
|
-
router.handleMessage(
|
|
1955
|
-
ws,
|
|
1956
|
-
JSON.stringify({
|
|
1957
|
-
type: "register",
|
|
1958
|
-
sidecarId: "sc-1",
|
|
1959
|
-
token: "tok",
|
|
1960
|
-
agentAddresses: ["agent@local"],
|
|
1961
|
-
}),
|
|
1962
|
-
);
|
|
1963
|
-
|
|
1964
|
-
// Wait for the ping timeout to fire.
|
|
1965
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1966
|
-
|
|
1967
|
-
expect(ws.closed).toBe(true);
|
|
1968
|
-
});
|
|
1969
|
-
|
|
1970
|
-
test("ping resets the liveness timer", async () => {
|
|
1971
|
-
const router = createSidecarRouter({
|
|
1972
|
-
requestTimeoutMs: 500,
|
|
1973
|
-
pingTimeoutMs: 100,
|
|
1974
|
-
});
|
|
1975
|
-
|
|
1976
|
-
const ws = createMockWs();
|
|
1977
|
-
router.handleOpen(ws);
|
|
1978
|
-
router.handleMessage(
|
|
1979
|
-
ws,
|
|
1980
|
-
JSON.stringify({
|
|
1981
|
-
type: "register",
|
|
1982
|
-
sidecarId: "sc-1",
|
|
1983
|
-
token: "tok",
|
|
1984
|
-
agentAddresses: [],
|
|
1985
|
-
}),
|
|
1986
|
-
);
|
|
1987
|
-
|
|
1988
|
-
// Send pings to keep the connection alive past the timeout.
|
|
1989
|
-
await new Promise((r) => setTimeout(r, 60));
|
|
1990
|
-
router.handleMessage(ws, JSON.stringify({ type: "ping" }));
|
|
1991
|
-
await new Promise((r) => setTimeout(r, 60));
|
|
1992
|
-
router.handleMessage(ws, JSON.stringify({ type: "ping" }));
|
|
1993
|
-
await new Promise((r) => setTimeout(r, 60));
|
|
1994
|
-
|
|
1995
|
-
expect(ws.closed).toBe(false);
|
|
1996
|
-
});
|
|
1997
|
-
});
|
|
1998
|
-
|
|
1999
|
-
describe("agent.reconnected event", () => {
|
|
2000
|
-
test("fires for each verified address on reconnect", async () => {
|
|
2001
|
-
const kp = await generateKeyPair();
|
|
2002
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
2003
|
-
const reconnected: string[] = [];
|
|
2004
|
-
|
|
2005
|
-
const router = createSidecarRouter({
|
|
2006
|
-
requestTimeoutMs: 5000,
|
|
2007
|
-
lookups: {
|
|
2008
|
-
lookupPublicKey: async (addr) =>
|
|
2009
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
2010
|
-
},
|
|
2011
|
-
});
|
|
2012
|
-
router.events.on("agent.reconnected", ({ agentAddress }) => {
|
|
2013
|
-
reconnected.push(agentAddress);
|
|
2014
|
-
});
|
|
2015
|
-
|
|
2016
|
-
const ws = createMockWs();
|
|
2017
|
-
router.handleOpen(ws);
|
|
2018
|
-
router.handleMessage(
|
|
2019
|
-
ws,
|
|
2020
|
-
JSON.stringify({
|
|
2021
|
-
type: "reconnect",
|
|
2022
|
-
sidecarId: "sc-1",
|
|
2023
|
-
token: "tok",
|
|
2024
|
-
agentAddresses: ["agent@local"],
|
|
2025
|
-
}),
|
|
2026
|
-
);
|
|
2027
|
-
|
|
2028
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2029
|
-
|
|
2030
|
-
const challengeFrame = ws.sent
|
|
2031
|
-
.map((s) => JSON.parse(s))
|
|
2032
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
2033
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
2034
|
-
const signature = signChallenge(nonce, address, kp.privateKey);
|
|
2035
|
-
|
|
2036
|
-
router.handleMessage(
|
|
2037
|
-
ws,
|
|
2038
|
-
JSON.stringify({
|
|
2039
|
-
type: "challenge.response",
|
|
2040
|
-
responses: [{ address, signature }],
|
|
2041
|
-
}),
|
|
2042
|
-
);
|
|
2043
|
-
|
|
2044
|
-
// Wait for async handleChallengeResponse to complete.
|
|
2045
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2046
|
-
|
|
2047
|
-
expect(reconnected).toEqual(["agent@local"]);
|
|
2048
|
-
});
|
|
2049
|
-
|
|
2050
|
-
test("does not fire for unverified addresses", async () => {
|
|
2051
|
-
const kp = await generateKeyPair();
|
|
2052
|
-
const wrongKp = await generateKeyPair();
|
|
2053
|
-
const publicKeyHex = hexEncode(kp.publicKey);
|
|
2054
|
-
const reconnected: string[] = [];
|
|
2055
|
-
|
|
2056
|
-
const router = createSidecarRouter({
|
|
2057
|
-
requestTimeoutMs: 5000,
|
|
2058
|
-
lookups: {
|
|
2059
|
-
lookupPublicKey: async (addr) =>
|
|
2060
|
-
addr === "agent@local" ? publicKeyHex : null,
|
|
2061
|
-
},
|
|
2062
|
-
});
|
|
2063
|
-
router.events.on("agent.reconnected", ({ agentAddress }) => {
|
|
2064
|
-
reconnected.push(agentAddress);
|
|
2065
|
-
});
|
|
2066
|
-
|
|
2067
|
-
const ws = createMockWs();
|
|
2068
|
-
router.handleOpen(ws);
|
|
2069
|
-
router.handleMessage(
|
|
2070
|
-
ws,
|
|
2071
|
-
JSON.stringify({
|
|
2072
|
-
type: "reconnect",
|
|
2073
|
-
sidecarId: "sc-1",
|
|
2074
|
-
token: "tok",
|
|
2075
|
-
agentAddresses: ["agent@local"],
|
|
2076
|
-
}),
|
|
2077
|
-
);
|
|
2078
|
-
|
|
2079
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2080
|
-
|
|
2081
|
-
const challengeFrame = ws.sent
|
|
2082
|
-
.map((s) => JSON.parse(s))
|
|
2083
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
2084
|
-
const { address, nonce } = challengeFrame.challenges[0];
|
|
2085
|
-
const signature = signChallenge(nonce, address, wrongKp.privateKey);
|
|
2086
|
-
|
|
2087
|
-
router.handleMessage(
|
|
2088
|
-
ws,
|
|
2089
|
-
JSON.stringify({
|
|
2090
|
-
type: "challenge.response",
|
|
2091
|
-
responses: [{ address, signature }],
|
|
2092
|
-
}),
|
|
2093
|
-
);
|
|
2094
|
-
|
|
2095
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2096
|
-
|
|
2097
|
-
expect(reconnected).toEqual([]);
|
|
2098
|
-
});
|
|
2099
|
-
|
|
2100
|
-
test("listener error does not prevent other addresses from reconnecting", async () => {
|
|
2101
|
-
const kp1 = await generateKeyPair();
|
|
2102
|
-
const kp2 = await generateKeyPair();
|
|
2103
|
-
const reconnected: string[] = [];
|
|
2104
|
-
|
|
2105
|
-
const router = createSidecarRouter({
|
|
2106
|
-
requestTimeoutMs: 5000,
|
|
2107
|
-
lookups: {
|
|
2108
|
-
lookupPublicKey: async (addr) => {
|
|
2109
|
-
if (addr === "agent1@local") return hexEncode(kp1.publicKey);
|
|
2110
|
-
if (addr === "agent2@local") return hexEncode(kp2.publicKey);
|
|
2111
|
-
return null;
|
|
2112
|
-
},
|
|
2113
|
-
},
|
|
2114
|
-
});
|
|
2115
|
-
router.events.on("agent.reconnected", ({ agentAddress }) => {
|
|
2116
|
-
if (agentAddress === "agent1@local") throw new Error("DB failure");
|
|
2117
|
-
reconnected.push(agentAddress);
|
|
2118
|
-
});
|
|
2119
|
-
|
|
2120
|
-
const ws = createMockWs();
|
|
2121
|
-
router.handleOpen(ws);
|
|
2122
|
-
router.handleMessage(
|
|
2123
|
-
ws,
|
|
2124
|
-
JSON.stringify({
|
|
2125
|
-
type: "reconnect",
|
|
2126
|
-
sidecarId: "sc-1",
|
|
2127
|
-
token: "tok",
|
|
2128
|
-
agentAddresses: ["agent1@local", "agent2@local"],
|
|
2129
|
-
}),
|
|
2130
|
-
);
|
|
2131
|
-
|
|
2132
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2133
|
-
|
|
2134
|
-
const challengeFrame = ws.sent
|
|
2135
|
-
.map((s) => JSON.parse(s))
|
|
2136
|
-
.find((f: { type: string }) => f.type === "challenge");
|
|
2137
|
-
|
|
2138
|
-
const responses = challengeFrame.challenges.map(
|
|
2139
|
-
(c: { address: string; nonce: string }) => ({
|
|
2140
|
-
address: c.address,
|
|
2141
|
-
signature: signChallenge(
|
|
2142
|
-
c.nonce,
|
|
2143
|
-
c.address,
|
|
2144
|
-
c.address === "agent1@local" ? kp1.privateKey : kp2.privateKey,
|
|
2145
|
-
),
|
|
2146
|
-
}),
|
|
2147
|
-
);
|
|
2148
|
-
|
|
2149
|
-
router.handleMessage(
|
|
2150
|
-
ws,
|
|
2151
|
-
JSON.stringify({ type: "challenge.response", responses }),
|
|
2152
|
-
);
|
|
2153
|
-
|
|
2154
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2155
|
-
|
|
2156
|
-
// agent2 should still be reconnected despite agent1's callback failure.
|
|
2157
|
-
expect(reconnected).toEqual(["agent2@local"]);
|
|
2158
|
-
expect(router.getRoutableAddresses()).not.toContain("agent1@local");
|
|
2159
|
-
expect(router.getRoutableAddresses()).toContain("agent2@local");
|
|
2160
|
-
|
|
2161
|
-
// agent1 should receive a challenge.failed frame indicating governance rejection.
|
|
2162
|
-
const failedFrames = ws.sent
|
|
2163
|
-
.map((s) => JSON.parse(s))
|
|
2164
|
-
.filter(
|
|
2165
|
-
(f: { type: string; address?: string }) =>
|
|
2166
|
-
f.type === "challenge.failed" && f.address === "agent1@local",
|
|
2167
|
-
);
|
|
2168
|
-
expect(failedFrames).toHaveLength(1);
|
|
2169
|
-
expect(failedFrames[0].reason).toContain("governance");
|
|
2170
|
-
});
|
|
2171
|
-
});
|
|
2172
|
-
|
|
2173
|
-
describe("configuration guards", () => {
|
|
2174
|
-
test("sendAgentDeploy without hub key throws without mutating routing table", async () => {
|
|
2175
|
-
const router = createSidecarRouter({
|
|
2176
|
-
requestTimeoutMs: 500,
|
|
2177
|
-
});
|
|
2178
|
-
|
|
2179
|
-
const ws = createMockWs();
|
|
2180
|
-
router.handleOpen(ws);
|
|
2181
|
-
router.handleMessage(
|
|
2182
|
-
ws,
|
|
2183
|
-
JSON.stringify({
|
|
2184
|
-
type: "register",
|
|
2185
|
-
sidecarId: "sc-1",
|
|
2186
|
-
token: "tok",
|
|
2187
|
-
agentAddresses: [],
|
|
2188
|
-
}),
|
|
2189
|
-
);
|
|
2190
|
-
|
|
2191
|
-
await expect(
|
|
2192
|
-
router.sendAgentDeploy("new-agent@local", {
|
|
2193
|
-
agentId: "a1",
|
|
2194
|
-
agentAddress: "new-agent@local",
|
|
2195
|
-
sessionId: "s1",
|
|
2196
|
-
principalId: "p1",
|
|
2197
|
-
tenantId: "t1",
|
|
2198
|
-
systemPrompt: "test",
|
|
2199
|
-
tools: [],
|
|
2200
|
-
grants: [],
|
|
2201
|
-
sources: [
|
|
2202
|
-
{
|
|
2203
|
-
id: "test:m",
|
|
2204
|
-
provider: "test",
|
|
2205
|
-
apiKey: "k",
|
|
2206
|
-
baseURL: "http://localhost",
|
|
2207
|
-
model: "m",
|
|
2208
|
-
},
|
|
2209
|
-
],
|
|
2210
|
-
defaultSource: "test:m",
|
|
2211
|
-
}),
|
|
2212
|
-
).rejects.toThrow("Hub signing key is required");
|
|
2213
|
-
|
|
2214
|
-
expect(router.getRoutableAddresses()).not.toContain("new-agent@local");
|
|
2215
|
-
});
|
|
2216
|
-
});
|
|
2217
|
-
});
|