@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41

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.
Files changed (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -0,0 +1,424 @@
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("GET /events/users/me — Desktop user stream auth", () => {
191
+ test("anonymous → 401", async () => {
192
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`);
193
+ expect(res.status).toBe(401);
194
+ });
195
+
196
+ test("ntok is refused for user stream", async () => {
197
+ const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "desktop-ntok-probe");
198
+ expect(minted.ok).toBe(true);
199
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(minted.token!) });
200
+ expect(res.status).toBe(403);
201
+ const body = await res.json() as any;
202
+ expect(body.error).toBe("user_token_required");
203
+ });
204
+
205
+ test("utok without network_id → 400", async () => {
206
+ const res = await fetch(`${BASE}/events/users/me`, { headers: auth(memberToken) });
207
+ expect(res.status).toBe(400);
208
+ const body = await res.json() as any;
209
+ expect(body.error).toBe("network_id required");
210
+ });
211
+
212
+ test("non-member utok → 403", async () => {
213
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(outsiderToken) });
214
+ expect(res.status).toBe(403);
215
+ const body = await res.json() as any;
216
+ expect(body.error).toBe("not a member of this network");
217
+ });
218
+
219
+ test("member utok → user stream connected frame keyed to authenticated user", async () => {
220
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(memberToken) });
221
+ expect(res.status).toBe(200);
222
+ expect(res.headers.get("content-type") || "").toContain("text/event-stream");
223
+ const reader = res.body!.getReader();
224
+ const connected = await readFrame(reader);
225
+ expect(connected.type).toBe("connected");
226
+ expect(connected.user).toBe(true);
227
+ expect(connected.network_id).toBe(memberNetworkId);
228
+ expect(connected.user_id).toBe(memberUserId);
229
+ await reader.cancel();
230
+ });
231
+ });
232
+
233
+ describe("GET /api/messages — client inbox count", () => {
234
+ test("alias filter returns only that inbox and pending_count for unacked rows", async () => {
235
+ const selfAlias = `user-${memberUserId}`;
236
+ db.run(
237
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
238
+ VALUES (?1, ?2, 'task', 'normal', 'unread self', 'agent-a', 0, ?3, datetime('now'))`,
239
+ [`msg_self_unread_${Date.now()}`, selfAlias, memberNetworkId],
240
+ );
241
+ db.run(
242
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
243
+ VALUES (?1, ?2, 'task', 'normal', 'read self', 'agent-a', 1, ?3, datetime('now'))`,
244
+ [`msg_self_read_${Date.now()}`, selfAlias, memberNetworkId],
245
+ );
246
+ db.run(
247
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
248
+ VALUES (?1, 'other-alias', 'task', 'normal', 'other inbox', 'agent-b', 0, ?2, datetime('now'))`,
249
+ [`msg_other_${Date.now()}`, memberNetworkId],
250
+ );
251
+
252
+ const res = await fetch(`${BASE}/api/messages?alias=${encodeURIComponent(selfAlias)}&network_id=${encodeURIComponent(memberNetworkId)}&limit=20`, {
253
+ headers: auth(memberToken),
254
+ });
255
+ expect(res.status).toBe(200);
256
+ const body = await res.json() as any;
257
+ expect(body.ok).toBe(true);
258
+ expect(body.pending_count).toBe(1);
259
+ expect(body.messages.length).toBeGreaterThanOrEqual(2);
260
+ expect(body.messages.every((m: any) => m.to_alias === selfAlias)).toBe(true);
261
+ expect(body.messages.some((m: any) => m.content === "other inbox")).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe("#461 observer receives third-party traffic summaries", () => {
266
+ test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
267
+ const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
268
+ const reader = res.body!.getReader();
269
+ await readFrame(reader); // connected
270
+
271
+ const secret = `SECRET-TASK-CONTENT-${Date.now()}`;
272
+ const dispatch = await fetch(`${BASE}/api/task`, {
273
+ method: "POST",
274
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
275
+ body: JSON.stringify({ alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId }),
276
+ });
277
+ expect([200, 202]).toContain(dispatch.status);
278
+ const dispatched = await dispatch.json() as any;
279
+ expect(dispatched.task_id).toBeTruthy();
280
+
281
+ const evt = await readFrame(reader);
282
+ expect(evt.type).toBe("new_task");
283
+ expect(evt.task_id).toBe(dispatched.task_id);
284
+ expect(evt.from).toBe("third-party-sender");
285
+ expect(evt.to).toBe(TARGET_ALIAS);
286
+ // Online session was seeded → delivered; offline would report queued.
287
+ expect(evt.status).toBe("delivered");
288
+ expect(evt.priority).toBe("high");
289
+ expect(evt.network_id).toBe(memberNetworkId);
290
+ expect(evt.scope).toBe("network");
291
+ // The acceptance line: summary must NOT leak the task body.
292
+ expect(JSON.stringify(evt)).not.toContain(secret);
293
+ await reader.cancel();
294
+ });
295
+
296
+ test("MCP send_reply → observer gets new_reply summary, reply text NOT in frame", async () => {
297
+ // Drive the REAL registered MCP handler (same interception pattern as
298
+ // ack-create-request.test.ts) with the member's network enforced, and
299
+ // read the observer stream over real HTTP.
300
+ const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
301
+ const reader = res.body!.getReader();
302
+ await readFrame(reader); // connected
303
+
304
+ const tools: Record<string, (args: any) => Promise<any>> = {};
305
+ const mcp = new McpServer({ name: "test", version: "0" }) as any;
306
+ mcp.tool = (name: string, _desc: string, _schema: any, handler: any) => { tools[name] = handler; };
307
+ registerTools(mcp, undefined, memberNetworkId, memberUserId);
308
+ expect(typeof tools["send_reply"]).toBe("function");
309
+
310
+ const secret = `SECRET-REPLY-TEXT-${Date.now()}`;
311
+ const result = await tools["send_reply"]({
312
+ alias: TARGET_ALIAS,
313
+ text: secret,
314
+ status: "replied",
315
+ from_session: "reply-worker",
316
+ });
317
+ const parsed = JSON.parse(result.content[0].text);
318
+ expect(parsed.ok).toBe(true);
319
+
320
+ const evt = await readFrame(reader);
321
+ expect(evt.type).toBe("new_reply");
322
+ expect(evt.from).toBe("reply-worker");
323
+ expect(evt.to).toBe(TARGET_ALIAS);
324
+ expect(evt.status).toBe("replied");
325
+ expect(evt.message_id).toBe(parsed.message_id);
326
+ expect(evt.network_id).toBe(memberNetworkId);
327
+ expect(JSON.stringify(evt)).not.toContain(secret);
328
+ await reader.cancel();
329
+ });
330
+ });
331
+
332
+ describe("#462 PUT /api/nodes/:ref/avatar + GET /api/nodes", () => {
333
+ test("set avatar → 200, GET /api/nodes returns it (cross-device persistence)", async () => {
334
+ const avatarUrl = "https://cdn.example.com/avatars/custom-1.png";
335
+ const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
336
+ method: "PUT",
337
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
338
+ body: JSON.stringify({ avatar_url: avatarUrl }),
339
+ });
340
+ expect(put.status).toBe(200);
341
+ const putBody = await put.json() as any;
342
+ expect(putBody.ok).toBe(true);
343
+ expect(putBody.avatar_url).toBe(avatarUrl);
344
+
345
+ const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
346
+ expect(list.status).toBe(200);
347
+ const listBody = await list.json() as any;
348
+ expect(listBody.ok).toBe(true);
349
+ const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
350
+ expect(row).toBeTruthy();
351
+ expect(row.avatar_url).toBe(avatarUrl);
352
+ // #312 discipline still holds — internals must not leak.
353
+ expect("config_snapshot" in row).toBe(false);
354
+ });
355
+
356
+ test("markup-hostile chars are stored NORMALIZED (href), not raw (通信龙 review finding 3)", async () => {
357
+ const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
358
+ method: "PUT",
359
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
360
+ body: JSON.stringify({ avatar_url: 'https://cdn.example.com/a"b.png' }),
361
+ });
362
+ expect(put.status).toBe(200);
363
+ const putBody = await put.json() as any;
364
+ expect(putBody.avatar_url).toBe("https://cdn.example.com/a%22b.png");
365
+ const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
366
+ expect(row.avatar_url).toBe("https://cdn.example.com/a%22b.png");
367
+ expect(row.avatar_url).not.toContain('"');
368
+ });
369
+
370
+ test("clear avatar with null → avatar_url null in list", async () => {
371
+ const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
372
+ method: "PUT",
373
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
374
+ body: JSON.stringify({ avatar_url: null }),
375
+ });
376
+ expect(put.status).toBe(200);
377
+ const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
378
+ const listBody = await list.json() as any;
379
+ const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
380
+ expect(row.avatar_url).toBeNull();
381
+ });
382
+
383
+ test("javascript: URL → 400 invalid_avatar_url, value NOT persisted", async () => {
384
+ const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
385
+ method: "PUT",
386
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
387
+ body: JSON.stringify({ avatar_url: "javascript:alert(1)" }),
388
+ });
389
+ expect(put.status).toBe(400);
390
+ const body = await put.json() as any;
391
+ expect(body.error).toBe("invalid_avatar_url");
392
+ const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
393
+ expect(row.avatar_url ?? null).toBeNull();
394
+ });
395
+
396
+ test("data: URL → 400", async () => {
397
+ const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
398
+ method: "PUT",
399
+ headers: { ...auth(memberToken), "Content-Type": "application/json" },
400
+ body: JSON.stringify({ avatar_url: "data:text/html,<script>alert(1)</script>" }),
401
+ });
402
+ expect(put.status).toBe(400);
403
+ });
404
+
405
+ test("anonymous → 401; non-member → 404/403 (network-scoped lookup)", async () => {
406
+ const anon = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
407
+ method: "PUT",
408
+ headers: { "Content-Type": "application/json" },
409
+ body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
410
+ });
411
+ expect(anon.status).toBe(401);
412
+
413
+ // Outsider's network scope can't even see the node → 404 (or 403 if
414
+ // visibility rules change later; both deny the write).
415
+ const outsider = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
416
+ method: "PUT",
417
+ headers: { ...auth(outsiderToken), "Content-Type": "application/json" },
418
+ body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
419
+ });
420
+ expect([403, 404]).toContain(outsider.status);
421
+ const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
422
+ expect(row.avatar_url ?? null).toBeNull();
423
+ });
424
+ });
@@ -0,0 +1,252 @@
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
+ createUserEventStream,
15
+ pushEvent,
16
+ pushNetworkObserverEvent,
17
+ pushUserEvent,
18
+ } from "./push";
19
+
20
+ afterEach(() => {
21
+ __resetSSEClientsForTest();
22
+ });
23
+
24
+ type Frame = Record<string, any>;
25
+
26
+ /** Read the next SSE data frame (skipping keepalive comments), with a
27
+ * hard timeout so a silent stream fails the test instead of hanging. */
28
+ async function readFrame(
29
+ reader: ReadableStreamDefaultReader<Uint8Array>,
30
+ timeoutMs = 2_000,
31
+ ): Promise<Frame> {
32
+ const decoder = new TextDecoder();
33
+ let buf = "";
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ const remaining = deadline - Date.now();
37
+ const result = await Promise.race([
38
+ reader.read(),
39
+ new Promise<never>((_, rej) =>
40
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), remaining),
41
+ ),
42
+ ]);
43
+ if (result.done) throw new Error("stream ended before a data frame arrived");
44
+ buf += decoder.decode(result.value, { stream: true });
45
+ const sep = buf.indexOf("\n\n");
46
+ if (sep === -1) continue;
47
+ const rawFrame = buf.slice(0, sep);
48
+ buf = buf.slice(sep + 2);
49
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
50
+ if (!dataLine) continue; // keepalive comment frame — keep reading
51
+ return JSON.parse(dataLine.slice(6));
52
+ }
53
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
54
+ }
55
+
56
+ describe("#461 network observer stream", () => {
57
+ test("observer receives connected frame, then new_task summary with metadata only", async () => {
58
+ const res = createNetworkObserverStream("net_obs_a");
59
+ const reader = res.body!.getReader();
60
+
61
+ const connected = await readFrame(reader);
62
+ expect(connected.type).toBe("connected");
63
+ expect(connected.observer).toBe(true);
64
+ expect(connected.network_id).toBe("net_obs_a");
65
+
66
+ pushNetworkObserverEvent("net_obs_a", {
67
+ type: "new_task",
68
+ task_id: "task-123",
69
+ from: "sender-agent",
70
+ to: "receiver-agent",
71
+ status: "delivered",
72
+ priority: "high",
73
+ });
74
+
75
+ const evt = await readFrame(reader);
76
+ expect(evt.type).toBe("new_task");
77
+ expect(evt.task_id).toBe("task-123");
78
+ expect(evt.from).toBe("sender-agent");
79
+ expect(evt.to).toBe("receiver-agent");
80
+ expect(evt.status).toBe("delivered");
81
+ expect(evt.priority).toBe("high");
82
+ expect(evt.network_id).toBe("net_obs_a");
83
+ expect(evt.scope).toBe("network");
84
+ // Summary contract: NO content field, ever.
85
+ expect("content" in evt).toBe(false);
86
+ expect("task" in evt).toBe(false);
87
+ await reader.cancel();
88
+ });
89
+
90
+ test("observer receives new_reply summary", async () => {
91
+ const res = createNetworkObserverStream("net_obs_reply");
92
+ const reader = res.body!.getReader();
93
+ await readFrame(reader); // connected
94
+
95
+ pushNetworkObserverEvent("net_obs_reply", {
96
+ type: "new_reply",
97
+ task_id: "parent-task",
98
+ message_id: "msg-9",
99
+ from: "worker",
100
+ to: "boss",
101
+ status: "completed",
102
+ });
103
+
104
+ const evt = await readFrame(reader);
105
+ expect(evt.type).toBe("new_reply");
106
+ expect(evt.task_id).toBe("parent-task");
107
+ expect(evt.message_id).toBe("msg-9");
108
+ expect(evt.status).toBe("completed");
109
+ expect("content" in evt).toBe(false);
110
+ expect("text" in evt).toBe(false);
111
+ await reader.cancel();
112
+ });
113
+
114
+ test("events are network-isolated: observer on net B never sees net A traffic", async () => {
115
+ const resB = createNetworkObserverStream("net_obs_b");
116
+ const readerB = resB.body!.getReader();
117
+ await readFrame(readerB); // connected
118
+
119
+ // Push to A first, then a marker to B. Delivery per key is
120
+ // synchronous and ordered, so if B's next frame is the marker,
121
+ // the A event provably never reached B.
122
+ pushNetworkObserverEvent("net_obs_a2", { type: "new_task", task_id: "leaked" });
123
+ pushNetworkObserverEvent("net_obs_b", { type: "new_task", task_id: "marker-b" });
124
+
125
+ const evt = await readFrame(readerB);
126
+ expect(evt.task_id).toBe("marker-b");
127
+ await readerB.cancel();
128
+ });
129
+
130
+ test("observer stream is isolated from session channels (both directions)", async () => {
131
+ const sessionRes = createSSEStream("agent-x", "net_obs_c");
132
+ const sessionReader = sessionRes.body!.getReader();
133
+ await readFrame(sessionReader); // connected
134
+
135
+ const obsRes = createNetworkObserverStream("net_obs_c");
136
+ const obsReader = obsRes.body!.getReader();
137
+ await readFrame(obsReader); // connected
138
+
139
+ // Session push must NOT hit the observer; observer push must NOT
140
+ // hit the session. Marker technique as above.
141
+ pushEvent("agent-x", { type: "new_task", inbox_count: 1, marker: "to-session" }, "net_obs_c");
142
+ pushNetworkObserverEvent("net_obs_c", { type: "new_task", task_id: "to-observer" });
143
+
144
+ const sessionEvt = await readFrame(sessionReader);
145
+ expect(sessionEvt.marker).toBe("to-session");
146
+ expect("task_id" in sessionEvt).toBe(false);
147
+
148
+ const obsEvt = await readFrame(obsReader);
149
+ expect(obsEvt.task_id).toBe("to-observer");
150
+ expect("marker" in obsEvt).toBe(false);
151
+
152
+ await sessionReader.cancel();
153
+ await obsReader.cancel();
154
+ });
155
+
156
+ test("null / undefined networkId is a silent no-op", () => {
157
+ // Must not throw — legacy null-network traffic has no observer
158
+ // stream by design (documented in #461 / the PR).
159
+ expect(() => pushNetworkObserverEvent(null, { type: "new_task" })).not.toThrow();
160
+ expect(() => pushNetworkObserverEvent(undefined, { type: "new_task" })).not.toThrow();
161
+ });
162
+
163
+ test("multiple observers on the same network all receive the event", async () => {
164
+ const r1 = createNetworkObserverStream("net_obs_multi");
165
+ const r2 = createNetworkObserverStream("net_obs_multi");
166
+ const reader1 = r1.body!.getReader();
167
+ const reader2 = r2.body!.getReader();
168
+ await readFrame(reader1);
169
+ await readFrame(reader2);
170
+
171
+ pushNetworkObserverEvent("net_obs_multi", { type: "new_task", task_id: "fanout" });
172
+
173
+ expect((await readFrame(reader1)).task_id).toBe("fanout");
174
+ expect((await readFrame(reader2)).task_id).toBe("fanout");
175
+ await reader1.cancel();
176
+ await reader2.cancel();
177
+ });
178
+ });
179
+
180
+ describe("Desktop user SSE stream", () => {
181
+ test("user stream receives connected frame, then desktop_message", async () => {
182
+ const res = createUserEventStream("net_user_a", "u_user_a");
183
+ const reader = res.body!.getReader();
184
+
185
+ const connected = await readFrame(reader);
186
+ expect(connected.type).toBe("connected");
187
+ expect(connected.user).toBe(true);
188
+ expect(connected.network_id).toBe("net_user_a");
189
+ expect(connected.user_id).toBe("u_user_a");
190
+
191
+ pushUserEvent("net_user_a", "u_user_a", {
192
+ type: "desktop_message",
193
+ message_id: "dm_one",
194
+ from: "agent-a",
195
+ message: "hello",
196
+ });
197
+
198
+ const evt = await readFrame(reader);
199
+ expect(evt.type).toBe("desktop_message");
200
+ expect(evt.message_id).toBe("dm_one");
201
+ expect(evt.from).toBe("agent-a");
202
+ expect(evt.message).toBe("hello");
203
+ expect(evt.network_id).toBe("net_user_a");
204
+ expect(evt.user_id).toBe("u_user_a");
205
+ expect(evt.scope).toBe("user");
206
+ await reader.cancel();
207
+ });
208
+
209
+ test("user stream is isolated from alias and observer key spaces", async () => {
210
+ const userRes = createUserEventStream("net_user_iso", "u_iso");
211
+ const userReader = userRes.body!.getReader();
212
+ await readFrame(userReader);
213
+
214
+ const sessionRes = createSSEStream("u_iso", "net_user_iso");
215
+ const sessionReader = sessionRes.body!.getReader();
216
+ await readFrame(sessionReader);
217
+
218
+ const observerRes = createNetworkObserverStream("net_user_iso");
219
+ const observerReader = observerRes.body!.getReader();
220
+ await readFrame(observerReader);
221
+
222
+ pushEvent("u_iso", { type: "alias_marker", marker: "alias" }, "net_user_iso");
223
+ pushNetworkObserverEvent("net_user_iso", { type: "observer_marker", marker: "observer" });
224
+ pushUserEvent("net_user_iso", "u_iso", { type: "desktop_message", marker: "user" });
225
+
226
+ expect((await readFrame(sessionReader)).marker).toBe("alias");
227
+ expect((await readFrame(observerReader)).marker).toBe("observer");
228
+ expect((await readFrame(userReader)).marker).toBe("user");
229
+
230
+ await userReader.cancel();
231
+ await sessionReader.cancel();
232
+ await observerReader.cancel();
233
+ });
234
+
235
+ test("same user and network fan out to multiple live clients; cancelling one leaves the other live", async () => {
236
+ const r1 = createUserEventStream("net_user_multi", "u_multi");
237
+ const r2 = createUserEventStream("net_user_multi", "u_multi");
238
+ const reader1 = r1.body!.getReader();
239
+ const reader2 = r2.body!.getReader();
240
+ await readFrame(reader1);
241
+ await readFrame(reader2);
242
+
243
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_1" });
244
+ expect((await readFrame(reader1)).message_id).toBe("dm_fanout_1");
245
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_1");
246
+
247
+ await reader1.cancel();
248
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_2" });
249
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_2");
250
+ await reader2.cancel();
251
+ });
252
+ });