@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.22
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/README.md +1 -1
- package/bin/commhub.ts +6 -2
- package/package.json +1 -1
- package/src/api-host-supervisors-fallback.test.ts +15 -5
- package/src/avatar-validate.test.ts +113 -0
- package/src/avatar-validate.ts +69 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/db.ts +10 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/push.ts +87 -7
- package/src/response-charset.test.ts +130 -0
- package/src/server.ts +2642 -0
- package/src/tools.ts +8 -1
- package/src/uploads-http.test.ts +11 -8
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// #461 + #462 — HTTP integration tests against the real Bun.serve hub.
|
|
2
|
+
//
|
|
3
|
+
// Boots a PRIVATE server instance via the #434 seam (`bootServer({ port: 0 })`,
|
|
4
|
+
// same signature as PR #438) and derives BASE from the actual bound port.
|
|
5
|
+
// This keeps the suite alive in aggregate runs: `await import("./server.js")`
|
|
6
|
+
// is a module-cache no-op when another file booted first, so relying on the
|
|
7
|
+
// import side effect + own PORT env leaves every fetch ConnectionRefused
|
|
8
|
+
// while the tests still "run" (通信龙 #461 review, finding 2). Gate:
|
|
9
|
+
// bun test src/uploads-http.test.ts src/observer-avatar-http.test.ts → 0 fail
|
|
10
|
+
//
|
|
11
|
+
// #461: GET /events/network/:id observer stream — membership auth incl.
|
|
12
|
+
// member-removal revocation, summary events for third-party traffic
|
|
13
|
+
// (REST dispatch + MCP send_reply), no content leakage.
|
|
14
|
+
// #462: PUT /api/nodes/:ref/avatar + GET /api/nodes round trip — persistence,
|
|
15
|
+
// href normalization, XSS-shaped rejects, clear semantics, write gate.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
18
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { tmpdir } from "os";
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { register, login, addNetworkMember, removeNetworkMember, createNetworkTokenForNode } from "./auth.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { db } from "./db.js";
|
|
25
|
+
|
|
26
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-obs-avatar-db-")) + "/commhub.db";
|
|
27
|
+
|
|
28
|
+
let BASE = "";
|
|
29
|
+
let privateServer: any = null;
|
|
30
|
+
|
|
31
|
+
let memberToken = "";
|
|
32
|
+
let memberNetworkId = "";
|
|
33
|
+
let memberUserId = "";
|
|
34
|
+
let outsiderToken = "";
|
|
35
|
+
let outsiderUserId = "";
|
|
36
|
+
const TARGET_ALIAS = "obs-target-agent";
|
|
37
|
+
const AVATAR_NODE_ID = "node_avatar_test_1";
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
// Kept for per-file runs launched without an external COMMHUB_DB (the
|
|
41
|
+
// documented contract still is to set it externally; see db-adapter.ts).
|
|
42
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
43
|
+
process.env.HOST = "127.0.0.1";
|
|
44
|
+
// (#438 corrective: importing server.js is side-effect-free — no
|
|
45
|
+
// default instance, no PORT juggling; only the private bootServer
|
|
46
|
+
// instance below listens.)
|
|
47
|
+
|
|
48
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
49
|
+
const pw = "BootstrapPw123Aa!";
|
|
50
|
+
|
|
51
|
+
const memberName = `obs_member_${suffix}`;
|
|
52
|
+
let r = register(memberName, pw, undefined, "seed");
|
|
53
|
+
if (!r.ok || !r.token) {
|
|
54
|
+
const lr = login(memberName, pw);
|
|
55
|
+
if (lr.token) { memberToken = lr.token; memberNetworkId = lr.network_id ?? ""; }
|
|
56
|
+
} else {
|
|
57
|
+
memberToken = r.token;
|
|
58
|
+
memberNetworkId = r.network_id ?? "";
|
|
59
|
+
}
|
|
60
|
+
expect(memberToken).toBeTruthy();
|
|
61
|
+
expect(memberNetworkId).toBeTruthy();
|
|
62
|
+
memberUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", memberName)?.user_id ?? "";
|
|
63
|
+
expect(memberUserId).toBeTruthy();
|
|
64
|
+
|
|
65
|
+
const outsiderName = `obs_outsider_${suffix}`;
|
|
66
|
+
const r2 = register(outsiderName, pw, undefined, "seed");
|
|
67
|
+
if (r2.ok && r2.token) outsiderToken = r2.token;
|
|
68
|
+
expect(outsiderToken).toBeTruthy();
|
|
69
|
+
outsiderUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", outsiderName)?.user_id ?? "";
|
|
70
|
+
expect(outsiderUserId).toBeTruthy();
|
|
71
|
+
|
|
72
|
+
// Target agent session in the member's network, freshly seen → online.
|
|
73
|
+
db.run(
|
|
74
|
+
`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
|
|
75
|
+
VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`,
|
|
76
|
+
[`resume_${suffix}`, TARGET_ALIAS, memberNetworkId]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Node row for the avatar round trip.
|
|
80
|
+
db.run(
|
|
81
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
82
|
+
VALUES (?1, 'avatar-test-node', ?2, ?3, datetime('now'), datetime('now'))`,
|
|
83
|
+
[AVATAR_NODE_ID, TARGET_ALIAS, memberNetworkId]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Private instance on an OS-assigned port — collision-free by
|
|
87
|
+
// construction (kernel-assigned).
|
|
88
|
+
const mod: any = await import("./server.js");
|
|
89
|
+
privateServer = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
|
+
BASE = `http://127.0.0.1:${privateServer.port}`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
try { privateServer?.stop?.(true); } catch {}
|
|
95
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function auth(token: string): Record<string, string> {
|
|
99
|
+
return { "Authorization": `Bearer ${token}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the next SSE data frame from a fetch Response, with timeout. */
|
|
103
|
+
async function readFrame(
|
|
104
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
105
|
+
timeoutMs = 3_000,
|
|
106
|
+
): Promise<Record<string, any>> {
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
let buf = "";
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const result = await Promise.race([
|
|
112
|
+
reader.read(),
|
|
113
|
+
new Promise<never>((_, rej) =>
|
|
114
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
|
|
115
|
+
),
|
|
116
|
+
]);
|
|
117
|
+
if (result.done) throw new Error("SSE stream ended unexpectedly");
|
|
118
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
119
|
+
const sep = buf.indexOf("\n\n");
|
|
120
|
+
if (sep === -1) continue;
|
|
121
|
+
const rawFrame = buf.slice(0, sep);
|
|
122
|
+
buf = buf.slice(sep + 2);
|
|
123
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
124
|
+
if (!dataLine) continue;
|
|
125
|
+
return JSON.parse(dataLine.slice(6));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("#461 GET /events/network/:id — auth", () => {
|
|
131
|
+
test("anonymous → 401", async () => {
|
|
132
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`);
|
|
133
|
+
expect(res.status).toBe(401);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("non-member user token → 403", async () => {
|
|
137
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
138
|
+
expect(res.status).toBe(403);
|
|
139
|
+
const body = await res.json() as any;
|
|
140
|
+
expect(body.ok).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("member user token → SSE stream with observer connected frame", async () => {
|
|
144
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(res.headers.get("content-type") || "").toContain("text/event-stream");
|
|
147
|
+
const reader = res.body!.getReader();
|
|
148
|
+
const connected = await readFrame(reader);
|
|
149
|
+
expect(connected.type).toBe("connected");
|
|
150
|
+
expect(connected.observer).toBe(true);
|
|
151
|
+
expect(connected.network_id).toBe(memberNetworkId);
|
|
152
|
+
await reader.cancel();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("membership removal revokes observer access — ntok exploit repro (通信龙 review finding 1)", async () => {
|
|
156
|
+
// Exact repro of the review's exploit: add user C to network A, mint
|
|
157
|
+
// an ntok BOUND to A, remove C from A — the same live ntok must lose
|
|
158
|
+
// the stream. network_members IS the revocation mechanism because
|
|
159
|
+
// removeNetworkMember does not revoke the user's tokens; the old
|
|
160
|
+
// `!role && authCtx.networkId !== observedNetId` OR-form let the
|
|
161
|
+
// token's network binding bypass the membership check (utok would
|
|
162
|
+
// not catch this: its authCtx.networkId is null, so only ntok
|
|
163
|
+
// exercises the vulnerable branch — verified by stubbing the OR
|
|
164
|
+
// back in: THIS test goes red, the utok variants stay green).
|
|
165
|
+
const added = addNetworkMember(memberNetworkId, outsiderUserId, "member");
|
|
166
|
+
expect(added.ok).toBe(true);
|
|
167
|
+
const minted = createNetworkTokenForNode(outsiderUserId, memberNetworkId, "revocation-probe");
|
|
168
|
+
expect(minted.ok).toBe(true);
|
|
169
|
+
const ntok = minted.token!;
|
|
170
|
+
expect(ntok.startsWith("ntok_")).toBe(true);
|
|
171
|
+
|
|
172
|
+
const whileMember = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
173
|
+
expect(whileMember.status).toBe(200);
|
|
174
|
+
const reader = whileMember.body!.getReader();
|
|
175
|
+
expect((await readFrame(reader)).type).toBe("connected");
|
|
176
|
+
await reader.cancel();
|
|
177
|
+
|
|
178
|
+
const removed = removeNetworkMember(memberNetworkId, outsiderUserId);
|
|
179
|
+
expect(removed.ok).toBe(true);
|
|
180
|
+
// The review's failing probe: /events/network/A with the still-live
|
|
181
|
+
// network-bound token → must now be 403, not 200.
|
|
182
|
+
const afterRemoval = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
183
|
+
expect(afterRemoval.status).toBe(403);
|
|
184
|
+
// utok of the removed user is denied too.
|
|
185
|
+
const utokAfter = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
186
|
+
expect(utokAfter.status).toBe(403);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("#461 observer receives third-party traffic summaries", () => {
|
|
191
|
+
test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
|
|
192
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
193
|
+
const reader = res.body!.getReader();
|
|
194
|
+
await readFrame(reader); // connected
|
|
195
|
+
|
|
196
|
+
const secret = `SECRET-TASK-CONTENT-${Date.now()}`;
|
|
197
|
+
const dispatch = await fetch(`${BASE}/api/task`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId }),
|
|
201
|
+
});
|
|
202
|
+
expect([200, 202]).toContain(dispatch.status);
|
|
203
|
+
const dispatched = await dispatch.json() as any;
|
|
204
|
+
expect(dispatched.task_id).toBeTruthy();
|
|
205
|
+
|
|
206
|
+
const evt = await readFrame(reader);
|
|
207
|
+
expect(evt.type).toBe("new_task");
|
|
208
|
+
expect(evt.task_id).toBe(dispatched.task_id);
|
|
209
|
+
expect(evt.from).toBe("third-party-sender");
|
|
210
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
211
|
+
// Online session was seeded → delivered; offline would report queued.
|
|
212
|
+
expect(evt.status).toBe("delivered");
|
|
213
|
+
expect(evt.priority).toBe("high");
|
|
214
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
215
|
+
expect(evt.scope).toBe("network");
|
|
216
|
+
// The acceptance line: summary must NOT leak the task body.
|
|
217
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
218
|
+
await reader.cancel();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("MCP send_reply → observer gets new_reply summary, reply text NOT in frame", async () => {
|
|
222
|
+
// Drive the REAL registered MCP handler (same interception pattern as
|
|
223
|
+
// ack-create-request.test.ts) with the member's network enforced, and
|
|
224
|
+
// read the observer stream over real HTTP.
|
|
225
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
226
|
+
const reader = res.body!.getReader();
|
|
227
|
+
await readFrame(reader); // connected
|
|
228
|
+
|
|
229
|
+
const tools: Record<string, (args: any) => Promise<any>> = {};
|
|
230
|
+
const mcp = new McpServer({ name: "test", version: "0" }) as any;
|
|
231
|
+
mcp.tool = (name: string, _desc: string, _schema: any, handler: any) => { tools[name] = handler; };
|
|
232
|
+
registerTools(mcp, undefined, memberNetworkId, memberUserId);
|
|
233
|
+
expect(typeof tools["send_reply"]).toBe("function");
|
|
234
|
+
|
|
235
|
+
const secret = `SECRET-REPLY-TEXT-${Date.now()}`;
|
|
236
|
+
const result = await tools["send_reply"]({
|
|
237
|
+
alias: TARGET_ALIAS,
|
|
238
|
+
text: secret,
|
|
239
|
+
status: "replied",
|
|
240
|
+
from_session: "reply-worker",
|
|
241
|
+
});
|
|
242
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
243
|
+
expect(parsed.ok).toBe(true);
|
|
244
|
+
|
|
245
|
+
const evt = await readFrame(reader);
|
|
246
|
+
expect(evt.type).toBe("new_reply");
|
|
247
|
+
expect(evt.from).toBe("reply-worker");
|
|
248
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
249
|
+
expect(evt.status).toBe("replied");
|
|
250
|
+
expect(evt.message_id).toBe(parsed.message_id);
|
|
251
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
252
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
253
|
+
await reader.cancel();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("#462 PUT /api/nodes/:ref/avatar + GET /api/nodes", () => {
|
|
258
|
+
test("set avatar → 200, GET /api/nodes returns it (cross-device persistence)", async () => {
|
|
259
|
+
const avatarUrl = "https://cdn.example.com/avatars/custom-1.png";
|
|
260
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({ avatar_url: avatarUrl }),
|
|
264
|
+
});
|
|
265
|
+
expect(put.status).toBe(200);
|
|
266
|
+
const putBody = await put.json() as any;
|
|
267
|
+
expect(putBody.ok).toBe(true);
|
|
268
|
+
expect(putBody.avatar_url).toBe(avatarUrl);
|
|
269
|
+
|
|
270
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
271
|
+
expect(list.status).toBe(200);
|
|
272
|
+
const listBody = await list.json() as any;
|
|
273
|
+
expect(listBody.ok).toBe(true);
|
|
274
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
275
|
+
expect(row).toBeTruthy();
|
|
276
|
+
expect(row.avatar_url).toBe(avatarUrl);
|
|
277
|
+
// #312 discipline still holds — internals must not leak.
|
|
278
|
+
expect("config_snapshot" in row).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("markup-hostile chars are stored NORMALIZED (href), not raw (通信龙 review finding 3)", async () => {
|
|
282
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
283
|
+
method: "PUT",
|
|
284
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify({ avatar_url: 'https://cdn.example.com/a"b.png' }),
|
|
286
|
+
});
|
|
287
|
+
expect(put.status).toBe(200);
|
|
288
|
+
const putBody = await put.json() as any;
|
|
289
|
+
expect(putBody.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
290
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
291
|
+
expect(row.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
292
|
+
expect(row.avatar_url).not.toContain('"');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("clear avatar with null → avatar_url null in list", async () => {
|
|
296
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
297
|
+
method: "PUT",
|
|
298
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
299
|
+
body: JSON.stringify({ avatar_url: null }),
|
|
300
|
+
});
|
|
301
|
+
expect(put.status).toBe(200);
|
|
302
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
303
|
+
const listBody = await list.json() as any;
|
|
304
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
305
|
+
expect(row.avatar_url).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("javascript: URL → 400 invalid_avatar_url, value NOT persisted", async () => {
|
|
309
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
310
|
+
method: "PUT",
|
|
311
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({ avatar_url: "javascript:alert(1)" }),
|
|
313
|
+
});
|
|
314
|
+
expect(put.status).toBe(400);
|
|
315
|
+
const body = await put.json() as any;
|
|
316
|
+
expect(body.error).toBe("invalid_avatar_url");
|
|
317
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
318
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("data: URL → 400", async () => {
|
|
322
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
323
|
+
method: "PUT",
|
|
324
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
325
|
+
body: JSON.stringify({ avatar_url: "data:text/html,<script>alert(1)</script>" }),
|
|
326
|
+
});
|
|
327
|
+
expect(put.status).toBe(400);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("anonymous → 401; non-member → 404/403 (network-scoped lookup)", async () => {
|
|
331
|
+
const anon = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
332
|
+
method: "PUT",
|
|
333
|
+
headers: { "Content-Type": "application/json" },
|
|
334
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
335
|
+
});
|
|
336
|
+
expect(anon.status).toBe(401);
|
|
337
|
+
|
|
338
|
+
// Outsider's network scope can't even see the node → 404 (or 403 if
|
|
339
|
+
// visibility rules change later; both deny the write).
|
|
340
|
+
const outsider = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
341
|
+
method: "PUT",
|
|
342
|
+
headers: { ...auth(outsiderToken), "Content-Type": "application/json" },
|
|
343
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
344
|
+
});
|
|
345
|
+
expect([403, 404]).toContain(outsider.status);
|
|
346
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
347
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// #461 — unit tests for the network observer SSE channel (push.ts).
|
|
2
|
+
//
|
|
3
|
+
// These tests read REAL bytes off the observer stream, so they are a
|
|
4
|
+
// live gate: stub pushNetworkObserverEvent out to a no-op and every
|
|
5
|
+
// "receives …" test below times out red. (Per 通信龙 2026-07-28 rule:
|
|
6
|
+
// a test that stays green when the core function is emptied is a fake
|
|
7
|
+
// gate.)
|
|
8
|
+
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
__resetSSEClientsForTest,
|
|
12
|
+
createNetworkObserverStream,
|
|
13
|
+
createSSEStream,
|
|
14
|
+
pushEvent,
|
|
15
|
+
pushNetworkObserverEvent,
|
|
16
|
+
} from "./push";
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
__resetSSEClientsForTest();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type Frame = Record<string, any>;
|
|
23
|
+
|
|
24
|
+
/** Read the next SSE data frame (skipping keepalive comments), with a
|
|
25
|
+
* hard timeout so a silent stream fails the test instead of hanging. */
|
|
26
|
+
async function readFrame(
|
|
27
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
28
|
+
timeoutMs = 2_000,
|
|
29
|
+
): Promise<Frame> {
|
|
30
|
+
const decoder = new TextDecoder();
|
|
31
|
+
let buf = "";
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
const remaining = deadline - Date.now();
|
|
35
|
+
const result = await Promise.race([
|
|
36
|
+
reader.read(),
|
|
37
|
+
new Promise<never>((_, rej) =>
|
|
38
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), remaining),
|
|
39
|
+
),
|
|
40
|
+
]);
|
|
41
|
+
if (result.done) throw new Error("stream ended before a data frame arrived");
|
|
42
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
43
|
+
const sep = buf.indexOf("\n\n");
|
|
44
|
+
if (sep === -1) continue;
|
|
45
|
+
const rawFrame = buf.slice(0, sep);
|
|
46
|
+
buf = buf.slice(sep + 2);
|
|
47
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
48
|
+
if (!dataLine) continue; // keepalive comment frame — keep reading
|
|
49
|
+
return JSON.parse(dataLine.slice(6));
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("#461 network observer stream", () => {
|
|
55
|
+
test("observer receives connected frame, then new_task summary with metadata only", async () => {
|
|
56
|
+
const res = createNetworkObserverStream("net_obs_a");
|
|
57
|
+
const reader = res.body!.getReader();
|
|
58
|
+
|
|
59
|
+
const connected = await readFrame(reader);
|
|
60
|
+
expect(connected.type).toBe("connected");
|
|
61
|
+
expect(connected.observer).toBe(true);
|
|
62
|
+
expect(connected.network_id).toBe("net_obs_a");
|
|
63
|
+
|
|
64
|
+
pushNetworkObserverEvent("net_obs_a", {
|
|
65
|
+
type: "new_task",
|
|
66
|
+
task_id: "task-123",
|
|
67
|
+
from: "sender-agent",
|
|
68
|
+
to: "receiver-agent",
|
|
69
|
+
status: "delivered",
|
|
70
|
+
priority: "high",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const evt = await readFrame(reader);
|
|
74
|
+
expect(evt.type).toBe("new_task");
|
|
75
|
+
expect(evt.task_id).toBe("task-123");
|
|
76
|
+
expect(evt.from).toBe("sender-agent");
|
|
77
|
+
expect(evt.to).toBe("receiver-agent");
|
|
78
|
+
expect(evt.status).toBe("delivered");
|
|
79
|
+
expect(evt.priority).toBe("high");
|
|
80
|
+
expect(evt.network_id).toBe("net_obs_a");
|
|
81
|
+
expect(evt.scope).toBe("network");
|
|
82
|
+
// Summary contract: NO content field, ever.
|
|
83
|
+
expect("content" in evt).toBe(false);
|
|
84
|
+
expect("task" in evt).toBe(false);
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("observer receives new_reply summary", async () => {
|
|
89
|
+
const res = createNetworkObserverStream("net_obs_reply");
|
|
90
|
+
const reader = res.body!.getReader();
|
|
91
|
+
await readFrame(reader); // connected
|
|
92
|
+
|
|
93
|
+
pushNetworkObserverEvent("net_obs_reply", {
|
|
94
|
+
type: "new_reply",
|
|
95
|
+
task_id: "parent-task",
|
|
96
|
+
message_id: "msg-9",
|
|
97
|
+
from: "worker",
|
|
98
|
+
to: "boss",
|
|
99
|
+
status: "completed",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const evt = await readFrame(reader);
|
|
103
|
+
expect(evt.type).toBe("new_reply");
|
|
104
|
+
expect(evt.task_id).toBe("parent-task");
|
|
105
|
+
expect(evt.message_id).toBe("msg-9");
|
|
106
|
+
expect(evt.status).toBe("completed");
|
|
107
|
+
expect("content" in evt).toBe(false);
|
|
108
|
+
expect("text" in evt).toBe(false);
|
|
109
|
+
await reader.cancel();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("events are network-isolated: observer on net B never sees net A traffic", async () => {
|
|
113
|
+
const resB = createNetworkObserverStream("net_obs_b");
|
|
114
|
+
const readerB = resB.body!.getReader();
|
|
115
|
+
await readFrame(readerB); // connected
|
|
116
|
+
|
|
117
|
+
// Push to A first, then a marker to B. Delivery per key is
|
|
118
|
+
// synchronous and ordered, so if B's next frame is the marker,
|
|
119
|
+
// the A event provably never reached B.
|
|
120
|
+
pushNetworkObserverEvent("net_obs_a2", { type: "new_task", task_id: "leaked" });
|
|
121
|
+
pushNetworkObserverEvent("net_obs_b", { type: "new_task", task_id: "marker-b" });
|
|
122
|
+
|
|
123
|
+
const evt = await readFrame(readerB);
|
|
124
|
+
expect(evt.task_id).toBe("marker-b");
|
|
125
|
+
await readerB.cancel();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("observer stream is isolated from session channels (both directions)", async () => {
|
|
129
|
+
const sessionRes = createSSEStream("agent-x", "net_obs_c");
|
|
130
|
+
const sessionReader = sessionRes.body!.getReader();
|
|
131
|
+
await readFrame(sessionReader); // connected
|
|
132
|
+
|
|
133
|
+
const obsRes = createNetworkObserverStream("net_obs_c");
|
|
134
|
+
const obsReader = obsRes.body!.getReader();
|
|
135
|
+
await readFrame(obsReader); // connected
|
|
136
|
+
|
|
137
|
+
// Session push must NOT hit the observer; observer push must NOT
|
|
138
|
+
// hit the session. Marker technique as above.
|
|
139
|
+
pushEvent("agent-x", { type: "new_task", inbox_count: 1, marker: "to-session" }, "net_obs_c");
|
|
140
|
+
pushNetworkObserverEvent("net_obs_c", { type: "new_task", task_id: "to-observer" });
|
|
141
|
+
|
|
142
|
+
const sessionEvt = await readFrame(sessionReader);
|
|
143
|
+
expect(sessionEvt.marker).toBe("to-session");
|
|
144
|
+
expect("task_id" in sessionEvt).toBe(false);
|
|
145
|
+
|
|
146
|
+
const obsEvt = await readFrame(obsReader);
|
|
147
|
+
expect(obsEvt.task_id).toBe("to-observer");
|
|
148
|
+
expect("marker" in obsEvt).toBe(false);
|
|
149
|
+
|
|
150
|
+
await sessionReader.cancel();
|
|
151
|
+
await obsReader.cancel();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("null / undefined networkId is a silent no-op", () => {
|
|
155
|
+
// Must not throw — legacy null-network traffic has no observer
|
|
156
|
+
// stream by design (documented in #461 / the PR).
|
|
157
|
+
expect(() => pushNetworkObserverEvent(null, { type: "new_task" })).not.toThrow();
|
|
158
|
+
expect(() => pushNetworkObserverEvent(undefined, { type: "new_task" })).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("multiple observers on the same network all receive the event", async () => {
|
|
162
|
+
const r1 = createNetworkObserverStream("net_obs_multi");
|
|
163
|
+
const r2 = createNetworkObserverStream("net_obs_multi");
|
|
164
|
+
const reader1 = r1.body!.getReader();
|
|
165
|
+
const reader2 = r2.body!.getReader();
|
|
166
|
+
await readFrame(reader1);
|
|
167
|
+
await readFrame(reader2);
|
|
168
|
+
|
|
169
|
+
pushNetworkObserverEvent("net_obs_multi", { type: "new_task", task_id: "fanout" });
|
|
170
|
+
|
|
171
|
+
expect((await readFrame(reader1)).task_id).toBe("fanout");
|
|
172
|
+
expect((await readFrame(reader2)).task_id).toBe("fanout");
|
|
173
|
+
await reader1.cancel();
|
|
174
|
+
await reader2.cancel();
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -97,6 +97,31 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
97
97
|
return `${networkId || "global"}:${sessionName}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// ── #461 network observer channels ───────────────────────────────────
|
|
101
|
+
// Dashboard (or any network member) subscribes GET /events/network/:id
|
|
102
|
+
// and receives SUMMARY events for ALL new_task / new_reply traffic in
|
|
103
|
+
// that network — not just tasks it sent/received. Summary events carry
|
|
104
|
+
// ids + routing metadata only, never task/reply content.
|
|
105
|
+
//
|
|
106
|
+
// Key scheme: leading NUL byte makes an observer key impossible to
|
|
107
|
+
// forge from the session scheme above (`${networkId}:${sessionName}`)
|
|
108
|
+
// for any networkId the hub itself issues (net_* / "global"). Only a
|
|
109
|
+
// master-token caller could even inject a NUL into network_id via the
|
|
110
|
+
// legacy ?network_id= path, and master token is root already.
|
|
111
|
+
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
112
|
+
|
|
113
|
+
function observerKey(networkId: string): string {
|
|
114
|
+
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render a client key for logs / stats. Observer keys embed a raw NUL
|
|
118
|
+
* byte (collision guard above); printing that verbatim makes grep treat
|
|
119
|
+
* hub logs as binary and breaks log shippers, and getSSEStats feeds
|
|
120
|
+
* /health. Display as a visible "\\0" escape instead. */
|
|
121
|
+
function printableKey(key: string): string {
|
|
122
|
+
return key.split("\u0000").join("\\0");
|
|
123
|
+
}
|
|
124
|
+
|
|
100
125
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
126
|
// Close + dispatch helpers
|
|
102
127
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -114,7 +139,7 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
114
139
|
// Already closed by the runtime, or controller errored. Either way
|
|
115
140
|
// we're done with it.
|
|
116
141
|
}
|
|
117
|
-
console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
|
|
142
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
118
143
|
}
|
|
119
144
|
|
|
120
145
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -215,8 +240,24 @@ function ensureLivenessSweep(): void {
|
|
|
215
240
|
|
|
216
241
|
/** 创建 SSE Response 并注册到 clients map */
|
|
217
242
|
export function createSSEStream(sessionName: string, networkId?: string | null): Response {
|
|
243
|
+
return createStreamForKey(
|
|
244
|
+
clientKey(sessionName, networkId),
|
|
245
|
+
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
250
|
+
* Shares the exact same registration / backpressure / liveness
|
|
251
|
+
* machinery as session streams; only the key scheme differs. */
|
|
252
|
+
export function createNetworkObserverStream(networkId: string): Response {
|
|
253
|
+
return createStreamForKey(
|
|
254
|
+
observerKey(networkId),
|
|
255
|
+
{ type: "connected", observer: true, network_id: networkId },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
|
|
218
260
|
const encoder = new TextEncoder();
|
|
219
|
-
const key = clientKey(sessionName, networkId);
|
|
220
261
|
let client: SSEClient;
|
|
221
262
|
|
|
222
263
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -231,13 +272,13 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
231
272
|
|
|
232
273
|
if (!clients.has(key)) clients.set(key, []);
|
|
233
274
|
clients.get(key)!.push(client);
|
|
234
|
-
console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
|
|
275
|
+
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
235
276
|
|
|
236
277
|
// Send initial connected frame through the backpressure guard so
|
|
237
278
|
// even the first byte respects the contract.
|
|
238
279
|
tryEnqueueBytes(
|
|
239
280
|
client,
|
|
240
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
281
|
+
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
241
282
|
);
|
|
242
283
|
|
|
243
284
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -280,7 +321,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
280
321
|
|
|
281
322
|
return new Response(stream, {
|
|
282
323
|
headers: {
|
|
283
|
-
|
|
324
|
+
// #426: explicit charset so legacy Windows clients (PowerShell 5.1's
|
|
325
|
+
// Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
|
|
326
|
+
// Content-Type omits the charset) decode UTF-8 payloads correctly.
|
|
327
|
+
// Hub itself was already writing clean UTF-8 bytes; the client-side
|
|
328
|
+
// 双重编码样式 mojibake was the header telling the client to guess.
|
|
329
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
284
330
|
"Cache-Control": "no-cache, no-transform",
|
|
285
331
|
"Connection": "keep-alive",
|
|
286
332
|
},
|
|
@@ -294,7 +340,7 @@ function pruneClosed(key: string): void {
|
|
|
294
340
|
const alive = arr.filter((c) => !c.closed);
|
|
295
341
|
if (alive.length === 0) {
|
|
296
342
|
clients.delete(key);
|
|
297
|
-
console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
|
|
343
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
|
|
298
344
|
} else if (alive.length !== arr.length) {
|
|
299
345
|
clients.set(key, alive);
|
|
300
346
|
}
|
|
@@ -347,6 +393,40 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
347
393
|
if (needPrune) pruneClosed(key);
|
|
348
394
|
}
|
|
349
395
|
|
|
396
|
+
/** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
|
|
397
|
+
*
|
|
398
|
+
* Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
|
|
399
|
+
* priority / message_id) — never task or reply content. The event is
|
|
400
|
+
* stamped with `network_id` + `scope: "network"` here so observers can
|
|
401
|
+
* always tell which network the event belongs to and that it came from
|
|
402
|
+
* the observer stream rather than their personal channel.
|
|
403
|
+
*
|
|
404
|
+
* No-op when networkId is falsy: legacy null-network traffic has no
|
|
405
|
+
* observer stream (the route requires an explicit network id). */
|
|
406
|
+
export function pushNetworkObserverEvent(
|
|
407
|
+
networkId: string | null | undefined,
|
|
408
|
+
event: Record<string, unknown>,
|
|
409
|
+
): void {
|
|
410
|
+
if (!networkId) return;
|
|
411
|
+
const key = observerKey(networkId);
|
|
412
|
+
const arr = clients.get(key);
|
|
413
|
+
if (!arr || arr.length === 0) return;
|
|
414
|
+
|
|
415
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
416
|
+
let needPrune = false;
|
|
417
|
+
|
|
418
|
+
for (const c of arr) {
|
|
419
|
+
if (c.closed) {
|
|
420
|
+
needPrune = true;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
424
|
+
if (result === "dead") needPrune = true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (needPrune) pruneClosed(key);
|
|
428
|
+
}
|
|
429
|
+
|
|
350
430
|
export function __resetSSEClientsForTest(): void {
|
|
351
431
|
for (const arr of clients.values()) {
|
|
352
432
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
@@ -365,7 +445,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
|
|
|
365
445
|
for (const [name, arr] of clients) {
|
|
366
446
|
const alive = arr.filter((c) => !c.closed);
|
|
367
447
|
if (alive.length === 0) continue;
|
|
368
|
-
sessions[name] = alive.length;
|
|
448
|
+
sessions[printableKey(name)] = alive.length;
|
|
369
449
|
total += alive.length;
|
|
370
450
|
}
|
|
371
451
|
return { total, sessions };
|