@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- 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/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3508 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2312 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
package/src/push.ts
CHANGED
|
@@ -97,6 +97,40 @@ 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
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
114
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
115
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
116
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
117
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
118
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
119
|
+
* silently over-filtered observer keys. */
|
|
120
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
121
|
+
|
|
122
|
+
function observerKey(networkId: string): string {
|
|
123
|
+
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Render a client key for logs / stats. Observer keys embed a raw NUL
|
|
127
|
+
* byte (collision guard above); printing that verbatim makes grep treat
|
|
128
|
+
* hub logs as binary and breaks log shippers, and getSSEStats feeds
|
|
129
|
+
* /health. Display as a visible "\\0" escape instead. */
|
|
130
|
+
function printableKey(key: string): string {
|
|
131
|
+
return key.split("\u0000").join("\\0");
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
135
|
// Close + dispatch helpers
|
|
102
136
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -114,7 +148,7 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
114
148
|
// Already closed by the runtime, or controller errored. Either way
|
|
115
149
|
// we're done with it.
|
|
116
150
|
}
|
|
117
|
-
console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
|
|
151
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -215,8 +249,24 @@ function ensureLivenessSweep(): void {
|
|
|
215
249
|
|
|
216
250
|
/** 创建 SSE Response 并注册到 clients map */
|
|
217
251
|
export function createSSEStream(sessionName: string, networkId?: string | null): Response {
|
|
252
|
+
return createStreamForKey(
|
|
253
|
+
clientKey(sessionName, networkId),
|
|
254
|
+
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
259
|
+
* Shares the exact same registration / backpressure / liveness
|
|
260
|
+
* machinery as session streams; only the key scheme differs. */
|
|
261
|
+
export function createNetworkObserverStream(networkId: string): Response {
|
|
262
|
+
return createStreamForKey(
|
|
263
|
+
observerKey(networkId),
|
|
264
|
+
{ type: "connected", observer: true, network_id: networkId },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
|
|
218
269
|
const encoder = new TextEncoder();
|
|
219
|
-
const key = clientKey(sessionName, networkId);
|
|
220
270
|
let client: SSEClient;
|
|
221
271
|
|
|
222
272
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -231,13 +281,13 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
231
281
|
|
|
232
282
|
if (!clients.has(key)) clients.set(key, []);
|
|
233
283
|
clients.get(key)!.push(client);
|
|
234
|
-
console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
|
|
284
|
+
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
235
285
|
|
|
236
286
|
// Send initial connected frame through the backpressure guard so
|
|
237
287
|
// even the first byte respects the contract.
|
|
238
288
|
tryEnqueueBytes(
|
|
239
289
|
client,
|
|
240
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
290
|
+
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
241
291
|
);
|
|
242
292
|
|
|
243
293
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -280,7 +330,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
280
330
|
|
|
281
331
|
return new Response(stream, {
|
|
282
332
|
headers: {
|
|
283
|
-
|
|
333
|
+
// #426: explicit charset so legacy Windows clients (PowerShell 5.1's
|
|
334
|
+
// Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
|
|
335
|
+
// Content-Type omits the charset) decode UTF-8 payloads correctly.
|
|
336
|
+
// Hub itself was already writing clean UTF-8 bytes; the client-side
|
|
337
|
+
// 双重编码样式 mojibake was the header telling the client to guess.
|
|
338
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
284
339
|
"Cache-Control": "no-cache, no-transform",
|
|
285
340
|
"Connection": "keep-alive",
|
|
286
341
|
},
|
|
@@ -294,7 +349,7 @@ function pruneClosed(key: string): void {
|
|
|
294
349
|
const alive = arr.filter((c) => !c.closed);
|
|
295
350
|
if (alive.length === 0) {
|
|
296
351
|
clients.delete(key);
|
|
297
|
-
console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
|
|
352
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
|
|
298
353
|
} else if (alive.length !== arr.length) {
|
|
299
354
|
clients.set(key, alive);
|
|
300
355
|
}
|
|
@@ -347,6 +402,40 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
347
402
|
if (needPrune) pruneClosed(key);
|
|
348
403
|
}
|
|
349
404
|
|
|
405
|
+
/** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
|
|
406
|
+
*
|
|
407
|
+
* Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
|
|
408
|
+
* priority / message_id) — never task or reply content. The event is
|
|
409
|
+
* stamped with `network_id` + `scope: "network"` here so observers can
|
|
410
|
+
* always tell which network the event belongs to and that it came from
|
|
411
|
+
* the observer stream rather than their personal channel.
|
|
412
|
+
*
|
|
413
|
+
* No-op when networkId is falsy: legacy null-network traffic has no
|
|
414
|
+
* observer stream (the route requires an explicit network id). */
|
|
415
|
+
export function pushNetworkObserverEvent(
|
|
416
|
+
networkId: string | null | undefined,
|
|
417
|
+
event: Record<string, unknown>,
|
|
418
|
+
): void {
|
|
419
|
+
if (!networkId) return;
|
|
420
|
+
const key = observerKey(networkId);
|
|
421
|
+
const arr = clients.get(key);
|
|
422
|
+
if (!arr || arr.length === 0) return;
|
|
423
|
+
|
|
424
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
425
|
+
let needPrune = false;
|
|
426
|
+
|
|
427
|
+
for (const c of arr) {
|
|
428
|
+
if (c.closed) {
|
|
429
|
+
needPrune = true;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
433
|
+
if (result === "dead") needPrune = true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (needPrune) pruneClosed(key);
|
|
437
|
+
}
|
|
438
|
+
|
|
350
439
|
export function __resetSSEClientsForTest(): void {
|
|
351
440
|
for (const arr of clients.values()) {
|
|
352
441
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
@@ -365,7 +454,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
|
|
|
365
454
|
for (const [name, arr] of clients) {
|
|
366
455
|
const alive = arr.filter((c) => !c.closed);
|
|
367
456
|
if (alive.length === 0) continue;
|
|
368
|
-
sessions[name] = alive.length;
|
|
457
|
+
sessions[printableKey(name)] = alive.length;
|
|
369
458
|
total += alive.length;
|
|
370
459
|
}
|
|
371
460
|
return { total, sessions };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// #434: real-server integration tests must know their DB before importing
|
|
2
|
+
// auth/db/server modules. Setting COMMHUB_DB in beforeAll is too late because
|
|
3
|
+
// ESM dependencies have already evaluated the db singleton.
|
|
4
|
+
if (!process.env.COMMHUB_DB) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"explicit_test_database_required: run `cd server && bun run test`, " +
|
|
7
|
+
"or set COMMHUB_DB to an isolated temporary path before running this file",
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_PORT, resolvePort } from "./resolve-port";
|
|
3
|
+
|
|
4
|
+
// The whole reason this module exists.
|
|
5
|
+
test("PORT=0 means an ephemeral port, not the production default", () => {
|
|
6
|
+
expect(resolvePort("0")).toBe(0);
|
|
7
|
+
expect(resolvePort("0")).not.toBe(DEFAULT_PORT);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("unset or empty falls back to the default", () => {
|
|
11
|
+
expect(resolvePort(undefined)).toBe(DEFAULT_PORT);
|
|
12
|
+
expect(resolvePort("")).toBe(DEFAULT_PORT);
|
|
13
|
+
expect(resolvePort(" ")).toBe(DEFAULT_PORT);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("surrounding whitespace is tolerated — it is trimmed, not treated as malformed", () => {
|
|
17
|
+
expect(resolvePort(" 9201 ")).toBe(9201);
|
|
18
|
+
expect(resolvePort("\t0\n")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("an explicit port is used verbatim", () => {
|
|
22
|
+
expect(resolvePort("9201")).toBe(9201);
|
|
23
|
+
expect(resolvePort("1")).toBe(1);
|
|
24
|
+
expect(resolvePort("65535")).toBe(65535);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Defaulting on a malformed value means a typo silently starts the server on
|
|
28
|
+
// the production port — on this fleet, on top of the running Hub.
|
|
29
|
+
test("a malformed value is rejected, never quietly defaulted", () => {
|
|
30
|
+
for (const bad of ["abc", "80a", "-1", "65536", "1.5", "0x10", "NaN", "Infinity", "9 200", "+80"]) {
|
|
31
|
+
expect(() => resolvePort(bad)).toThrow();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the rejection names the value and the accepted range", () => {
|
|
36
|
+
try {
|
|
37
|
+
resolvePort("abc");
|
|
38
|
+
throw new Error("should have thrown");
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
expect(e.message).toContain("abc");
|
|
41
|
+
expect(e.message).toContain("0 and 65535");
|
|
42
|
+
expect(e.message).toContain("ephemeral");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a caller can override the fallback without touching the default", () => {
|
|
47
|
+
expect(resolvePort(undefined, 3000)).toBe(3000);
|
|
48
|
+
expect(resolvePort("0", 3000)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("server.ts resolves PORT through this module, not through `|| DEFAULT`", () => {
|
|
52
|
+
const src = require("fs").readFileSync(require("path").join(import.meta.dir, "server.ts"), "utf8");
|
|
53
|
+
const code = src.split("\n").filter((l: string) => !l.trim().startsWith("//")).join("\n");
|
|
54
|
+
expect(code).toContain("resolvePort(process.env.PORT)");
|
|
55
|
+
// `Number(env) || default` is the exact shape that swallowed the 0.
|
|
56
|
+
expect(code).not.toMatch(/Number\(process\.env\.PORT\)\s*\|\|/);
|
|
57
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Resolve the listen port from the environment.
|
|
2
|
+
//
|
|
3
|
+
// `Number(process.env.PORT) || 9200` swallows a legitimate `0`. `Number("0")`
|
|
4
|
+
// is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for
|
|
5
|
+
// an ephemeral port — silently became 9200, the production Hub port.
|
|
6
|
+
//
|
|
7
|
+
// Three consequences, and the middle one is the worst:
|
|
8
|
+
//
|
|
9
|
+
// 1. On a host where 9200 is already taken (a running Hub), a test that sets
|
|
10
|
+
// PORT=0 dies with EADDRINUSE and reads as a product bug.
|
|
11
|
+
// task-lifecycle-watcher.test.ts fails on main today for exactly this.
|
|
12
|
+
// 2. On a host where 9200 is FREE, the same test passes — by binding 9200.
|
|
13
|
+
// It is green because it grabbed the production port, not because PORT=0
|
|
14
|
+
// worked. Green for the wrong reason is worse than red.
|
|
15
|
+
// 3. Anyone asking for an ephemeral port gets the production port instead.
|
|
16
|
+
//
|
|
17
|
+
// The file already knew: `bootServer` uses `opts.port ?? PORT` with a comment
|
|
18
|
+
// saying `||` "would swallow a legitimate 0". The rule was one level up from
|
|
19
|
+
// where it was needed.
|
|
20
|
+
//
|
|
21
|
+
// A malformed value is rejected rather than defaulted. Falling back to 9200 on
|
|
22
|
+
// `PORT=abc` means a typo silently starts the server somewhere the operator did
|
|
23
|
+
// not ask for — and on this fleet that somewhere is production.
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_PORT = 9200;
|
|
26
|
+
|
|
27
|
+
export function resolvePort(raw: string | undefined, fallback = DEFAULT_PORT): number {
|
|
28
|
+
// Unset or empty means "not specified". An empty string is what a shell
|
|
29
|
+
// exports for an unset variable it still passes along, so treating it as 0
|
|
30
|
+
// would make `PORT= anet hub start` bind an ephemeral port by accident.
|
|
31
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
32
|
+
|
|
33
|
+
// Decimal digits only, after trimming. `Number()` alone accepts "0x10" (16)
|
|
34
|
+
// and " 9200 ", so a value that does not look like a port would still resolve
|
|
35
|
+
// to one — quietly, and to a different number than the operator typed.
|
|
36
|
+
const text = raw.trim();
|
|
37
|
+
const n = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
38
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`PORT must be an integer between 0 and 65535 (0 asks the OS for an ephemeral port); got ${JSON.stringify(raw)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
@@ -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,211 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-rest-columns-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
8
|
+
|
|
9
|
+
let db: typeof import("./db.js").db;
|
|
10
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let base = "";
|
|
12
|
+
let token = "";
|
|
13
|
+
let networkId = "";
|
|
14
|
+
|
|
15
|
+
const INTERNAL_SENTINEL = "future_internal_only";
|
|
16
|
+
const sortedKeys = (value: Record<string, unknown>): string[] => Object.keys(value).sort();
|
|
17
|
+
const sorted = (values: readonly string[]): string[] => [...values].sort();
|
|
18
|
+
|
|
19
|
+
// Independent wire-contract golden. Do not derive these expectations from
|
|
20
|
+
// rest-projections.ts: doing so makes a deleted projection column disappear
|
|
21
|
+
// from both the response and the expected value, producing a false green.
|
|
22
|
+
const GOLDEN_RESPONSE_KEYS = {
|
|
23
|
+
networkList: [
|
|
24
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
25
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "name",
|
|
26
|
+
],
|
|
27
|
+
// 🔴 #1097:GET /api/auth/me 走的是 auth.ts 的 getUserAllNetworks(),
|
|
28
|
+
// 与 GET /api/networks 的 admin 分支**不是同一条查询**。
|
|
29
|
+
// server.ts:985 直接返回原始行,没有 :1166 那个 withNetworkNameAlias,
|
|
30
|
+
// 所以比 networkList 少一个 "name"。这里逐字写死,不从 rest-projections.ts 推导
|
|
31
|
+
// —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
|
|
32
|
+
authMeNetwork: [
|
|
33
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role",
|
|
35
|
+
],
|
|
36
|
+
networkDetail: [
|
|
37
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
38
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
39
|
+
],
|
|
40
|
+
statusSession: [
|
|
41
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
42
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
43
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
44
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
45
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
46
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
47
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
48
|
+
"external_schedules", "runtime", "host", "process_telemetry",
|
|
49
|
+
],
|
|
50
|
+
task: [
|
|
51
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
52
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
53
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
|
|
54
|
+
"completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
|
|
55
|
+
],
|
|
56
|
+
audit: [
|
|
57
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
58
|
+
"ip", "network_id", "created_at",
|
|
59
|
+
],
|
|
60
|
+
taskEvent: [
|
|
61
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
62
|
+
"created_at", "network_id",
|
|
63
|
+
],
|
|
64
|
+
completion: [
|
|
65
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
66
|
+
"duration_minutes", "network_id", "completed_at",
|
|
67
|
+
],
|
|
68
|
+
scheduledTask: [
|
|
69
|
+
"schedule_id", "network_id", "name", "target_node_id", "target_alias",
|
|
70
|
+
"task_content", "priority", "schedule_type", "timezone", "overlap_policy",
|
|
71
|
+
"misfire_policy", "status", "next_run_at", "last_run_at", "revision",
|
|
72
|
+
"created_at", "updated_at", "schedule",
|
|
73
|
+
],
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
async function api(path: string): Promise<any> {
|
|
77
|
+
const response = await fetch(`${base}${path}`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
79
|
+
});
|
|
80
|
+
expect(response.status).toBe(200);
|
|
81
|
+
return response.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
({ db } = await import("./db.js"));
|
|
86
|
+
const { register } = await import("./auth.js");
|
|
87
|
+
const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
|
|
88
|
+
expect(auth.ok).toBe(true);
|
|
89
|
+
token = auth.token!;
|
|
90
|
+
networkId = auth.network_id!;
|
|
91
|
+
|
|
92
|
+
// Simulate future migrations. Any REST SELECT * silently broadcasts these
|
|
93
|
+
// columns; every endpoint below must keep them private without needing a
|
|
94
|
+
// code change when the table grows.
|
|
95
|
+
for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
|
|
96
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
db.run(
|
|
100
|
+
`INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
|
|
101
|
+
VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
|
|
102
|
+
[networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
|
|
103
|
+
);
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
|
|
106
|
+
VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
|
|
107
|
+
[networkId],
|
|
108
|
+
);
|
|
109
|
+
db.run(
|
|
110
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
111
|
+
VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
|
|
112
|
+
[networkId],
|
|
113
|
+
);
|
|
114
|
+
db.run(
|
|
115
|
+
`INSERT INTO completions (id, session_name, task, result, network_id)
|
|
116
|
+
VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
|
|
117
|
+
[networkId],
|
|
118
|
+
);
|
|
119
|
+
db.run(
|
|
120
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
|
|
121
|
+
VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
|
|
122
|
+
[networkId],
|
|
123
|
+
);
|
|
124
|
+
db.run(
|
|
125
|
+
`INSERT INTO scheduled_tasks
|
|
126
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias,
|
|
127
|
+
task_content, schedule_type, schedule_json, timezone, next_run_at)
|
|
128
|
+
VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
|
|
129
|
+
'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
|
|
130
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
|
|
131
|
+
[networkId],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const mod = await import("./server.js");
|
|
135
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
136
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterAll(() => {
|
|
140
|
+
try { server?.stop(true); } catch {}
|
|
141
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("#311 REST response projections are stable across future ALTER TABLE", () => {
|
|
145
|
+
test("network list and detail preserve their existing public keys", async () => {
|
|
146
|
+
const list = await api("/api/networks");
|
|
147
|
+
const row = list.networks.find((item: any) => item.network_id === networkId);
|
|
148
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
|
|
149
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
150
|
+
|
|
151
|
+
const detail = await api(`/api/networks/${networkId}`);
|
|
152
|
+
expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
|
|
153
|
+
expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
|
|
157
|
+
// tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
|
|
158
|
+
// `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
|
|
159
|
+
// 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
|
|
160
|
+
// 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
|
|
161
|
+
// (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
|
|
162
|
+
// 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
|
|
163
|
+
test("auth/me networks come from an explicit column list, not SELECT *", async () => {
|
|
164
|
+
const me = await api("/api/auth/me");
|
|
165
|
+
const row = me.networks.find((item: any) => item.network_id === networkId);
|
|
166
|
+
expect(row).toBeDefined();
|
|
167
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
|
|
168
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("full status preserves telemetry compatibility without storage drift", async () => {
|
|
172
|
+
const body = await api(`/api/status?network_id=${networkId}`);
|
|
173
|
+
const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
|
|
174
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
|
|
175
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
176
|
+
expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("task list and task detail expose the same explicit contract", async () => {
|
|
180
|
+
const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
|
|
181
|
+
expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
182
|
+
expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
183
|
+
|
|
184
|
+
const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
|
|
185
|
+
expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
186
|
+
expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("audit, task-event, and completion rows are explicit", async () => {
|
|
190
|
+
const audit = await api("/api/audit-log?action=shape_action");
|
|
191
|
+
expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
|
|
192
|
+
expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
193
|
+
|
|
194
|
+
const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
|
|
195
|
+
expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
|
|
196
|
+
expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
197
|
+
|
|
198
|
+
const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
|
|
199
|
+
expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
|
|
200
|
+
expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
|
|
204
|
+
const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
|
|
205
|
+
const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
|
|
206
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
|
|
207
|
+
expect(row.created_by).toBeUndefined();
|
|
208
|
+
expect(row.schedule_json).toBeUndefined();
|
|
209
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
});
|