@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.23
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/api-nodes-shape.test.ts +26 -10
- 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 +42 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- 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/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +2831 -0
- package/src/tools.ts +26 -3
- package/src/uploads-http.test.ts +11 -8
|
@@ -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 };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// #426 unit tests for the withUtf8CharsetContentType helper — the
|
|
2
|
+
// piece that rewrites text-y Content-Type headers to include
|
|
3
|
+
// charset=utf-8 so legacy Windows clients don't fall back to
|
|
4
|
+
// ISO-8859-1 decoding. The helper lives in server/src/index.ts;
|
|
5
|
+
// to keep the test standalone we re-implement its pure semantics here
|
|
6
|
+
// and assert those. Any drift between this pure copy and the
|
|
7
|
+
// production helper is caught by the "identical semantics" test at
|
|
8
|
+
// the bottom via a source grep.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Pure re-implementation — MUST match server/src/index.ts:withUtf8CharsetContentType.
|
|
15
|
+
function withUtf8CharsetContentType(res: Response): Response {
|
|
16
|
+
const ct = res.headers.get("content-type");
|
|
17
|
+
if (!ct) return res;
|
|
18
|
+
if (/;\s*charset=/i.test(ct)) return res;
|
|
19
|
+
const trimmed = ct.trim();
|
|
20
|
+
if (
|
|
21
|
+
trimmed === "application/json" ||
|
|
22
|
+
trimmed.startsWith("text/") ||
|
|
23
|
+
trimmed.startsWith("application/json;") ||
|
|
24
|
+
trimmed === "application/ld+json" ||
|
|
25
|
+
trimmed === "application/xml"
|
|
26
|
+
) {
|
|
27
|
+
res.headers.set("content-type", `${trimmed}; charset=utf-8`);
|
|
28
|
+
}
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRes(contentType: string | null, body = ""): Response {
|
|
33
|
+
return new Response(body, { headers: contentType ? { "content-type": contentType } : {} });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("withUtf8CharsetContentType — #426", () => {
|
|
37
|
+
test("appends charset to text/event-stream", () => {
|
|
38
|
+
const res = withUtf8CharsetContentType(makeRes("text/event-stream"));
|
|
39
|
+
expect(res.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("appends charset to application/json (bare)", () => {
|
|
43
|
+
const res = withUtf8CharsetContentType(makeRes("application/json"));
|
|
44
|
+
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("appends charset to text/plain", () => {
|
|
48
|
+
const res = withUtf8CharsetContentType(makeRes("text/plain"));
|
|
49
|
+
expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("appends charset to text/html", () => {
|
|
53
|
+
const res = withUtf8CharsetContentType(makeRes("text/html"));
|
|
54
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no-op when charset already present (case-insensitive)", () => {
|
|
58
|
+
for (const ct of [
|
|
59
|
+
"application/json; charset=utf-8",
|
|
60
|
+
"application/json;charset=UTF-8",
|
|
61
|
+
"text/event-stream;Charset=us-ascii",
|
|
62
|
+
"text/plain; CHARSET=iso-8859-1",
|
|
63
|
+
]) {
|
|
64
|
+
const res = withUtf8CharsetContentType(makeRes(ct));
|
|
65
|
+
expect(res.headers.get("content-type")).toBe(ct);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no-op on binary types (opaque)", () => {
|
|
70
|
+
for (const ct of [
|
|
71
|
+
"application/octet-stream",
|
|
72
|
+
"image/png",
|
|
73
|
+
"image/jpeg",
|
|
74
|
+
"video/mp4",
|
|
75
|
+
"application/pdf",
|
|
76
|
+
"font/woff2",
|
|
77
|
+
]) {
|
|
78
|
+
const res = withUtf8CharsetContentType(makeRes(ct));
|
|
79
|
+
expect(res.headers.get("content-type")).toBe(ct);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("no-op when Content-Type header is absent entirely (returns as-is)", () => {
|
|
84
|
+
const res = withUtf8CharsetContentType(makeRes(null));
|
|
85
|
+
expect(res.headers.get("content-type")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("handles application/ld+json / application/xml", () => {
|
|
89
|
+
expect(withUtf8CharsetContentType(makeRes("application/ld+json")).headers.get("content-type"))
|
|
90
|
+
.toBe("application/ld+json; charset=utf-8");
|
|
91
|
+
expect(withUtf8CharsetContentType(makeRes("application/xml")).headers.get("content-type"))
|
|
92
|
+
.toBe("application/xml; charset=utf-8");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("preserves non-charset parameters and appends charset (application/json; boundary=…)", () => {
|
|
96
|
+
// Bun's Response.json emits `application/json;charset=utf-8`; the test
|
|
97
|
+
// above covers that no-op path. When a caller sends a parametrised
|
|
98
|
+
// header like `application/json; profile=…`, the helper still recognises
|
|
99
|
+
// it via the `application/json;` prefix and appends the charset.
|
|
100
|
+
const res = withUtf8CharsetContentType(makeRes("application/json; profile=example"));
|
|
101
|
+
expect(res.headers.get("content-type")).toBe("application/json; profile=example; charset=utf-8");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns the same Response instance (mutates in place)", () => {
|
|
105
|
+
const input = makeRes("text/event-stream");
|
|
106
|
+
const output = withUtf8CharsetContentType(input);
|
|
107
|
+
expect(output).toBe(input);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Drift guard: the pure copy in this file MUST match the production
|
|
111
|
+
// helper's branching set. If someone edits one without touching the
|
|
112
|
+
// other, this test fails visibly instead of the helper going silently
|
|
113
|
+
// out of sync. Kept as fragment-level greps (not whole-body match) so
|
|
114
|
+
// added comments don't break the assertion.
|
|
115
|
+
test("production helper covers the same content-type branches", () => {
|
|
116
|
+
const src = readFileSync(join(import.meta.dir, "server.ts"), "utf-8");
|
|
117
|
+
expect(src).toContain("function withUtf8CharsetContentType(res: Response): Response {");
|
|
118
|
+
expect(src).toContain("if (/;\\s*charset=/i.test(ct)) return res;");
|
|
119
|
+
for (const branch of [
|
|
120
|
+
'trimmed === "application/json"',
|
|
121
|
+
'trimmed.startsWith("text/")',
|
|
122
|
+
'trimmed.startsWith("application/json;")',
|
|
123
|
+
'trimmed === "application/ld+json"',
|
|
124
|
+
'trimmed === "application/xml"',
|
|
125
|
+
]) {
|
|
126
|
+
expect(src).toContain(branch);
|
|
127
|
+
}
|
|
128
|
+
expect(src).toContain('res.headers.set("content-type", `${trimmed}; charset=utf-8`);');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #498 regression — the send_reply hub response gains a `warning` field
|
|
7
|
+
// pointing at commhub_send_task ONLY when the target alias resolves to
|
|
8
|
+
// a real agent node (i.e. not hub / not api / not an unbound alias).
|
|
9
|
+
// Silence-affirms the Dashboard path.
|
|
10
|
+
//
|
|
11
|
+
// Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
|
|
12
|
+
// hint — deleting it doesn't break any behavior, so it's exactly the
|
|
13
|
+
// kind of code a future refactor drops silently. This test is the
|
|
14
|
+
// tripwire: if the branch goes away, this test turns red.
|
|
15
|
+
//
|
|
16
|
+
// witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
|
|
17
|
+
// I confirmed both assertions fail cleanly when the `warning` branch is
|
|
18
|
+
// reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
|
|
19
|
+
// this suite genuinely catches regressions rather than green-when-empty.
|
|
20
|
+
|
|
21
|
+
const NET_ID = "net_498_warn";
|
|
22
|
+
const USER_ID = "u_498_warn";
|
|
23
|
+
const NODE_ID = "node_498_warn";
|
|
24
|
+
const AGENT_ALIAS = "peer-agent-498";
|
|
25
|
+
const AGENT_TOK_ID = "tok_498_warn";
|
|
26
|
+
|
|
27
|
+
interface ToolHandler {
|
|
28
|
+
(args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
29
|
+
}
|
|
30
|
+
interface Reply {
|
|
31
|
+
ok?: boolean;
|
|
32
|
+
message_id?: string;
|
|
33
|
+
session_status?: string;
|
|
34
|
+
warning?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
41
|
+
try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
44
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
45
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
46
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
47
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
48
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(cleanup);
|
|
52
|
+
afterAll(cleanup);
|
|
53
|
+
|
|
54
|
+
function seed() {
|
|
55
|
+
db.run(
|
|
56
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
57
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
58
|
+
[USER_ID, USER_ID],
|
|
59
|
+
);
|
|
60
|
+
db.run(
|
|
61
|
+
`INSERT INTO networks (network_id, network_name, owner_id, created_at)
|
|
62
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
63
|
+
[NET_ID, NET_ID, USER_ID],
|
|
64
|
+
);
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
67
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
68
|
+
[USER_ID, NET_ID],
|
|
69
|
+
);
|
|
70
|
+
db.run(
|
|
71
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
72
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
|
|
73
|
+
[NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
|
|
74
|
+
);
|
|
75
|
+
db.run(
|
|
76
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
77
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
78
|
+
[`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
|
|
79
|
+
);
|
|
80
|
+
db.run(
|
|
81
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
82
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
83
|
+
[AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeTask(to: string): string {
|
|
88
|
+
const id = "t_" + Math.random().toString(36).slice(2, 14);
|
|
89
|
+
db.run(
|
|
90
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
|
|
91
|
+
VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
|
|
92
|
+
[id, "dispatcher", to, NET_ID],
|
|
93
|
+
);
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildHandlers(): Record<string, ToolHandler> {
|
|
98
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
99
|
+
const tools: Record<string, ToolHandler> = {};
|
|
100
|
+
const origTool = server.tool.bind(server);
|
|
101
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
102
|
+
tools[name] = handler;
|
|
103
|
+
return origTool(name, _desc, _schema, handler);
|
|
104
|
+
};
|
|
105
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
106
|
+
if (origRegisterTool) {
|
|
107
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
108
|
+
tools[name] = handler;
|
|
109
|
+
return origRegisterTool(name, _cfg, handler);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Bind as a network token whose caller alias is the sender.
|
|
113
|
+
registerTools(
|
|
114
|
+
server,
|
|
115
|
+
undefined,
|
|
116
|
+
/* enforceNetworkId */ NET_ID,
|
|
117
|
+
/* enforceUserId */ USER_ID,
|
|
118
|
+
/* callerAlias */ AGENT_ALIAS,
|
|
119
|
+
/* callerTokenIsNetwork */ true,
|
|
120
|
+
/* callerTokenId */ AGENT_TOK_ID,
|
|
121
|
+
);
|
|
122
|
+
return tools;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
126
|
+
const r = await handler(args);
|
|
127
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("send_reply warning field (#498)", () => {
|
|
131
|
+
test("target alias resolves to a real agent → response includes `warning` field", async () => {
|
|
132
|
+
seed();
|
|
133
|
+
const tools = buildHandlers();
|
|
134
|
+
const sendReply = tools["send_reply"];
|
|
135
|
+
expect(sendReply).toBeDefined();
|
|
136
|
+
|
|
137
|
+
const taskId = makeTask(AGENT_ALIAS);
|
|
138
|
+
const reply = await call(sendReply, {
|
|
139
|
+
alias: AGENT_ALIAS,
|
|
140
|
+
text: "peer reply (should trigger warning)",
|
|
141
|
+
in_reply_to: taskId,
|
|
142
|
+
status: "replied",
|
|
143
|
+
from_session: AGENT_ALIAS,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(reply.ok).toBe(true);
|
|
147
|
+
expect(reply.warning).toBeDefined();
|
|
148
|
+
expect(typeof reply.warning).toBe("string");
|
|
149
|
+
const w = reply.warning as string;
|
|
150
|
+
// Action-pointing content — witnessed-red methodology means each
|
|
151
|
+
// assertion here must be one a future revert would break, not a
|
|
152
|
+
// shape-only check that passes on empty string.
|
|
153
|
+
expect(w).toContain("commhub_send_task");
|
|
154
|
+
// Alias value is INLINED (not just "<alias>") so the LLM has a
|
|
155
|
+
// copy-pasteable next command. This is the specific design 通信龙
|
|
156
|
+
// called out on 2026-07-30.
|
|
157
|
+
expect(w).toContain(`alias="${AGENT_ALIAS}"`);
|
|
158
|
+
expect(w).toContain("agent peers");
|
|
159
|
+
expect(w).toContain("not see this reply in real time");
|
|
160
|
+
expect(w).toContain("RFC-030");
|
|
161
|
+
expect(w).toContain("全网规则");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
|
|
165
|
+
seed();
|
|
166
|
+
const tools = buildHandlers();
|
|
167
|
+
const sendReply = tools["send_reply"];
|
|
168
|
+
|
|
169
|
+
const taskId = makeTask("hub");
|
|
170
|
+
const reply = await call(sendReply, {
|
|
171
|
+
alias: "hub",
|
|
172
|
+
text: "dashboard reply",
|
|
173
|
+
in_reply_to: taskId,
|
|
174
|
+
status: "replied",
|
|
175
|
+
from_session: AGENT_ALIAS,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(reply.ok).toBe(true);
|
|
179
|
+
// Silence-affirms the Dashboard path. Every reply carrying a
|
|
180
|
+
// warning would train the LLM to ignore it, so absence here is
|
|
181
|
+
// load-bearing.
|
|
182
|
+
expect(reply.warning).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("target alias is `api` (Dashboard path) → NO warning field", async () => {
|
|
186
|
+
seed();
|
|
187
|
+
const tools = buildHandlers();
|
|
188
|
+
const sendReply = tools["send_reply"];
|
|
189
|
+
|
|
190
|
+
const taskId = makeTask("api");
|
|
191
|
+
const reply = await call(sendReply, {
|
|
192
|
+
alias: "api",
|
|
193
|
+
text: "dashboard reply via api",
|
|
194
|
+
in_reply_to: taskId,
|
|
195
|
+
status: "replied",
|
|
196
|
+
from_session: AGENT_ALIAS,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(reply.ok).toBe(true);
|
|
200
|
+
expect(reply.warning).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|