@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.61

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 (143) 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 +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-dedup-request-id.test.ts +229 -0
  99. package/src/send-reply-agent-warning.test.ts +202 -0
  100. package/src/send-reply-alias-optional.test.ts +131 -0
  101. package/src/send-reply-attachments.test.ts +458 -0
  102. package/src/send_dedup.test.ts +54 -0
  103. package/src/send_dedup.ts +52 -6
  104. package/src/serve-error.test.ts +38 -0
  105. package/src/serve-error.ts +27 -0
  106. package/src/server.ts +3901 -0
  107. package/src/shared/external-schedule-contract.ts +60 -0
  108. package/src/side-thread-command-transport.test.ts +302 -0
  109. package/src/side-thread-command-transport.ts +318 -0
  110. package/src/side-thread-http-integration.test.ts +233 -0
  111. package/src/side-thread-http.ts +504 -0
  112. package/src/side-thread-production.test.ts +72 -0
  113. package/src/side-thread-production.ts +118 -0
  114. package/src/side-thread.test.ts +1067 -0
  115. package/src/side-thread.ts +2495 -0
  116. package/src/skillhub-http.test.ts +92 -0
  117. package/src/start-node.test.ts +109 -0
  118. package/src/stop-delete-node.test.ts +1164 -0
  119. package/src/task-auth-origin-http.test.ts +91 -0
  120. package/src/task-auth-origin.test.ts +24 -0
  121. package/src/task-auth-origin.ts +7 -0
  122. package/src/task-consumption.test.ts +482 -0
  123. package/src/task-diagnostic-http.test.ts +149 -0
  124. package/src/task-diagnostic.test.ts +115 -0
  125. package/src/task-diagnostic.ts +118 -0
  126. package/src/task-idempotency-mcp.test.ts +142 -0
  127. package/src/task-idempotency.test.ts +60 -0
  128. package/src/task-idempotency.ts +75 -0
  129. package/src/task-lifecycle-watcher.test.ts +204 -0
  130. package/src/task-lifecycle-watcher.ts +49 -0
  131. package/src/task-network-resolution-http.test.ts +141 -0
  132. package/src/tasks-pagination-http.test.ts +141 -0
  133. package/src/tools-list-json-schema.test.ts +66 -0
  134. package/src/tools.ts +3280 -206
  135. package/src/update-provider.test.ts +65 -0
  136. package/src/upload-early-reject-guard.test.ts +155 -0
  137. package/src/uploads-http.test.ts +97 -15
  138. package/src/uploads.test.ts +66 -0
  139. package/src/uploads.ts +130 -5
  140. package/src/user-inbox-by-agent-http.test.ts +161 -0
  141. package/src/user-inbox-netid-notnull.test.ts +122 -0
  142. package/src/user-inbox-read-http.test.ts +223 -0
  143. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,150 @@
1
+ // 同一 alias 换 resume_id 上报时,hub 的 report_status 走的是「DELETE 旧行 + INSERT 新行」,
2
+ // ON CONFLICT 里的 COALESCE 一次都不会触发 —— 上报里没带的描述性列(version / agent /
3
+ // hostname / 遥测 / registered_at)全部变 NULL。
4
+ //
5
+ // 2026-09-04 DEV 真机(hub 日志):
6
+ // 12:46:47 grok-v1 (sdk-n_6c) → report_status: working version=2.5.0-preview.64
7
+ // 12:46:53 grok-v1 (grok-cli) → report_status: working ← TUI 的 MCP 上报,不带 version
8
+ // 12:46:54 grok-v1 (sdk-n_6c) → report_status: idle ← 又换回来,也不带 version
9
+ // 结果:version=NULL、registered_at 被改成 12:46:54。舰队里 grok 3/10、claude 5/15 有 version,
10
+ // codex 34/34 有 —— 差别就是有没有第二个上报者。
11
+ //
12
+ // 判据:换 resume_id 之后,只有一行;上报了的列以上报为准,没上报的列从被替换的那行接手。
13
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { db } from "./db.js";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { registerTools } from "./tools.js";
17
+
18
+ const NET_ID = "net_handover";
19
+ const USER_ID = "u_handover";
20
+ const NODE_ID = "n_handover";
21
+ const ALIAS = "grok-handover";
22
+ const TOK_ID = "tok_handover";
23
+ const RESUME_SDK = `sdk-${NODE_ID}`;
24
+ const RESUME_CLI = `grok-cli-${NODE_ID}`;
25
+ const OLD_REGISTERED_AT = "2026-01-02 03:04:05";
26
+
27
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ for (const [sql, params] of [
31
+ ["DELETE FROM agent_telemetry WHERE network_id = ?1", [NET_ID]],
32
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
33
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
34
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
35
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
36
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
37
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
38
+ ] as const) {
39
+ try { db.run(sql, params as any); } catch {}
40
+ }
41
+ }
42
+ beforeEach(cleanup);
43
+ afterAll(cleanup);
44
+
45
+ function seed() {
46
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
48
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
51
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
52
+ [NODE_ID, ALIAS, ALIAS, NET_ID, "dev-box"],
53
+ );
54
+ // agent-node 自己注册的那行:带 version / agent / hostname / 遥测 / 早就存在的 registered_at
55
+ db.run(
56
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, version, agent, hostname, server, project_dir,
57
+ cpu_cores, mem_total_gb, disk_total_gb, registered_at, updated_at, last_seen_at)
58
+ VALUES (?1, ?2, 'working', ?3, ?4, '2.5.0-preview.64', 'agent-node:grok-build-cli', 'dev-box', 'dev-box', '/home/x/proj',
59
+ 8, 61.4, 500, ?5, datetime('now'), datetime('now'))`,
60
+ [RESUME_SDK, ALIAS, NODE_ID, NET_ID, OLD_REGISTERED_AT],
61
+ );
62
+ db.run(
63
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
64
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
65
+ [TOK_ID, USER_ID, NET_ID, `node:${ALIAS}`, `hash_${TOK_ID}`],
66
+ );
67
+ }
68
+
69
+ function buildHandlers(): Record<string, ToolHandler> {
70
+ const server = new McpServer({ name: "test", version: "0" }) as any;
71
+ const tools: Record<string, ToolHandler> = {};
72
+ const origTool = server.tool.bind(server);
73
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
74
+ const origRegisterTool = server.registerTool?.bind(server);
75
+ if (origRegisterTool) {
76
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
77
+ }
78
+ registerTools(server, undefined, NET_ID, USER_ID, ALIAS, true, TOK_ID);
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<any> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0]!.text);
85
+ }
86
+
87
+ type Row = { resume_id: string; status: string; version: string | null; agent: string | null; hostname: string | null;
88
+ cpu_cores: number | null; mem_total_gb: number | null; registered_at: string; task: string | null };
89
+ function rows(): Row[] {
90
+ return db.all("SELECT resume_id, status, version, agent, hostname, cpu_cores, mem_total_gb, registered_at, task FROM sessions WHERE alias = ?1 AND network_id = ?2", [ALIAS, NET_ID]) as Row[];
91
+ }
92
+
93
+ describe("report_status: 同 alias 换 resume_id 时,描述性列从被替换的那行接手", () => {
94
+ test("第二个上报者(grok-cli)不带 version/agent/遥测 → 这些列保留,status/task 以本次为准,仍只有一行", async () => {
95
+ seed();
96
+ const tools = buildHandlers();
97
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", task: "PING 已收到", network_id: NET_ID });
98
+ expect(r.ok, JSON.stringify(r)).toBe(true);
99
+ const all = rows();
100
+ expect(all.length).toBe(1);
101
+ const row = all[0]!;
102
+ expect(row.resume_id).toBe(RESUME_CLI);
103
+ expect(row.status).toBe("working");
104
+ expect(row.task).toBe("PING 已收到");
105
+ expect(row.version).toBe("2.5.0-preview.64");
106
+ expect(row.agent).toBe("agent-node:grok-build-cli");
107
+ expect(row.hostname).toBe("dev-box");
108
+ expect(row.cpu_cores).toBe(8);
109
+ expect(row.mem_total_gb).toBe(61.4);
110
+ expect(row.registered_at).toBe(OLD_REGISTERED_AT);
111
+ });
112
+
113
+ test("上报里带了的列以上报为准(version 变了就用新的)", async () => {
114
+ seed();
115
+ const tools = buildHandlers();
116
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "idle", version: "9.9.9", hostname: "other-box", network_id: NET_ID });
117
+ expect(r.ok, JSON.stringify(r)).toBe(true);
118
+ const row = rows()[0]!;
119
+ expect(rows().length).toBe(1);
120
+ expect(row.version).toBe("9.9.9");
121
+ expect(row.hostname).toBe("other-box");
122
+ expect(row.agent).toBe("agent-node:grok-build-cli");
123
+ });
124
+
125
+ test("再换回原 resume_id(sdk)也不丢:两个上报者轮流说话,version 始终在", async () => {
126
+ seed();
127
+ const tools = buildHandlers();
128
+ await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", network_id: NET_ID });
129
+ const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
130
+ expect(r.ok, JSON.stringify(r)).toBe(true);
131
+ const all = rows();
132
+ expect(all.length).toBe(1);
133
+ expect(all[0]!.resume_id).toBe(RESUME_SDK);
134
+ expect(all[0]!.status).toBe("idle");
135
+ expect(all[0]!.version).toBe("2.5.0-preview.64");
136
+ expect(all[0]!.registered_at).toBe(OLD_REGISTERED_AT);
137
+ });
138
+
139
+ test("没有别的行可接手时(全新 alias)行为不变:INSERT 一行,registered_at 取 now", async () => {
140
+ seed();
141
+ db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]);
142
+ const tools = buildHandlers();
143
+ const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
144
+ expect(r.ok, JSON.stringify(r)).toBe(true);
145
+ const all = rows();
146
+ expect(all.length).toBe(1);
147
+ expect(all[0]!.version).toBeNull();
148
+ expect(all[0]!.registered_at).not.toBe(OLD_REGISTERED_AT);
149
+ });
150
+ });
@@ -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,216 @@
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", "rules_file_capable", "skills_capable", "files_capable", "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
+ "thread_id", "turn_id",
56
+ ],
57
+ audit: [
58
+ "id", "user_id", "username", "action", "target_type", "target_id", "detail",
59
+ "ip", "network_id", "created_at",
60
+ ],
61
+ taskEvent: [
62
+ "id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
63
+ "created_at", "network_id",
64
+ ],
65
+ completion: [
66
+ "id", "session_name", "task", "result", "artifacts", "score",
67
+ "duration_minutes", "network_id", "completed_at",
68
+ ],
69
+ scheduledTask: [
70
+ "schedule_id", "network_id", "name", "target_node_id", "target_alias",
71
+ "task_content", "priority", "schedule_type", "timezone", "overlap_policy",
72
+ "misfire_policy", "status", "next_run_at", "last_run_at", "revision",
73
+ "created_at", "updated_at", "schedule",
74
+ ],
75
+ } as const;
76
+
77
+ async function api(path: string): Promise<any> {
78
+ const response = await fetch(`${base}${path}`, {
79
+ headers: { Authorization: `Bearer ${token}` },
80
+ });
81
+ expect(response.status).toBe(200);
82
+ return response.json();
83
+ }
84
+
85
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
86
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
87
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
88
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
89
+ beforeAll(async () => {
90
+ ({ db } = await import("./db.js"));
91
+ const { register } = await import("./auth.js");
92
+ const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
93
+ expect(auth.ok).toBe(true);
94
+ token = auth.token!;
95
+ networkId = auth.network_id!;
96
+
97
+ // Simulate future migrations. Any REST SELECT * silently broadcasts these
98
+ // columns; every endpoint below must keep them private without needing a
99
+ // code change when the table grows.
100
+ for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
101
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
102
+ }
103
+
104
+ db.run(
105
+ `INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
106
+ VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
107
+ [networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
108
+ );
109
+ db.run(
110
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
111
+ VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
112
+ [networkId],
113
+ );
114
+ db.run(
115
+ `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
116
+ VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
117
+ [networkId],
118
+ );
119
+ db.run(
120
+ `INSERT INTO completions (id, session_name, task, result, network_id)
121
+ VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
122
+ [networkId],
123
+ );
124
+ db.run(
125
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
126
+ VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
127
+ [networkId],
128
+ );
129
+ db.run(
130
+ `INSERT INTO scheduled_tasks
131
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias,
132
+ task_content, schedule_type, schedule_json, timezone, next_run_at)
133
+ VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
134
+ 'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
135
+ '{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
136
+ [networkId],
137
+ );
138
+
139
+ const mod = await import("./server.js");
140
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
141
+ base = `http://127.0.0.1:${server.port}`;
142
+ }, 30_000);
143
+
144
+ afterAll(() => {
145
+ try { server?.stop(true); } catch {}
146
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
147
+ });
148
+
149
+ describe("#311 REST response projections are stable across future ALTER TABLE", () => {
150
+ test("network list and detail preserve their existing public keys", async () => {
151
+ const list = await api("/api/networks");
152
+ const row = list.networks.find((item: any) => item.network_id === networkId);
153
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
154
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
155
+
156
+ const detail = await api(`/api/networks/${networkId}`);
157
+ expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
158
+ expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
159
+ });
160
+
161
+ // 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
162
+ // tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
163
+ // `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
164
+ // 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
165
+ // 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
166
+ // (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
167
+ // 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
168
+ test("auth/me networks come from an explicit column list, not SELECT *", async () => {
169
+ const me = await api("/api/auth/me");
170
+ const row = me.networks.find((item: any) => item.network_id === networkId);
171
+ expect(row).toBeDefined();
172
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
173
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
174
+ });
175
+
176
+ test("full status preserves telemetry compatibility without storage drift", async () => {
177
+ const body = await api(`/api/status?network_id=${networkId}`);
178
+ const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
179
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
180
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
181
+ expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
182
+ });
183
+
184
+ test("task list and task detail expose the same explicit contract", async () => {
185
+ const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
186
+ expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
187
+ expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
188
+
189
+ const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
190
+ expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
191
+ expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
192
+ });
193
+
194
+ test("audit, task-event, and completion rows are explicit", async () => {
195
+ const audit = await api("/api/audit-log?action=shape_action");
196
+ expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
197
+ expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
198
+
199
+ const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
200
+ expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
201
+ expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
202
+
203
+ const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
204
+ expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
205
+ expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
206
+ });
207
+
208
+ test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
209
+ const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
210
+ const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
211
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
212
+ expect(row.created_by).toBeUndefined();
213
+ expect(row.schedule_json).toBeUndefined();
214
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
215
+ });
216
+ });