@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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 (170) hide show
  1. package/README.md +130 -10
  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-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,211 @@
1
+ // report_status 的 host 遥测 schema 必须接受 agent-node **声明会发**的 null。
2
+ //
3
+ // 来源不是推理,是 #1225 复现容器里量到的一次真崩:`--network none` 里
4
+ // agent-node 一启动就死在
5
+ // MCP error -32602: Invalid input: expected string, received null at host.ip
6
+ // 而 register() 是 `await callCommHub("report_status", …)` 且没有 catch ——
7
+ // 于是**整个节点进程当场退出**,和 runtime 无关(opencode/claude/codex 一样)。
8
+ //
9
+ // 触发条件不是"容器",是"这台机器没有非回环 IPv4":
10
+ // agent-node 的 firstNonInternalIPv4()(host-telemetry.ts:48-59)在那种机器上
11
+ // 返回 null,而 HostTelemetry 的类型本来就写着 `ip: string | null`。
12
+ //
13
+ // 🔴 这里断言的是 **schema 本身**,不是 handler。直接调 handler 会绕过 zod ——
14
+ // 而缺陷恰恰只存在于 zod 那一层,绕过去就永远绿。
15
+ import { describe, expect, test } from "bun:test";
16
+ import { z } from "zod";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ function captureToolSchemas(): Record<string, any> {
21
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
22
+ const schemas: Record<string, any> = {};
23
+ const origTool = mcp.tool.bind(mcp);
24
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
25
+ schemas[name] = schema;
26
+ return origTool(name, desc, schema, handler);
27
+ };
28
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
29
+ return schemas;
30
+ }
31
+
32
+ const schemas = captureToolSchemas();
33
+
34
+ describe("report_status host telemetry schema", () => {
35
+ test("前提:抓到的确实是 report_status 的 host 形状", () => {
36
+ expect(schemas.report_status).toBeTruthy();
37
+ expect(schemas.report_status.host).toBeTruthy();
38
+ });
39
+
40
+ test("🔴 host.ip = null 必须被接受(没有非回环 IPv4 的机器就是发 null)", () => {
41
+ const host = schemas.report_status.host;
42
+ const parsed = host.parse({ hostname: "boxy", ip: null });
43
+ expect(parsed.ip).toBeNull();
44
+ });
45
+
46
+ test("🔴 host.hostname = null 同样被接受(与 ip 同为字符串字段,一起对齐)", () => {
47
+ const host = schemas.report_status.host;
48
+ expect(host.parse({ hostname: null, ip: "10.0.0.2" }).hostname).toBeNull();
49
+ });
50
+
51
+ test("agent-node 那条完整心跳负载整体过 schema", () => {
52
+ // 形状抄自 agent-node/src/host-telemetry.ts 的 HostTelemetry:
53
+ // 没有网络、没有 /proc 时每一项都可能是 null。
54
+ const full = {
55
+ hostname: "unknown", ip: null,
56
+ cpu_load_1min: null, cpu_cores: null,
57
+ mem_total_gb: null, mem_used_gb: null, mem_avail_gb: null,
58
+ disk_total_gb: null, disk_used_gb: null, disk_avail_gb: null,
59
+ };
60
+ expect(() => schemas.report_status.host.parse(full)).not.toThrow();
61
+ });
62
+
63
+ test("放宽的只有 null —— 类型错的值仍然被拒", () => {
64
+ const host = schemas.report_status.host;
65
+ expect(() => host.parse({ ip: 12345 })).toThrow();
66
+ expect(() => host.parse({ ip: "x".repeat(201) })).toThrow();
67
+ });
68
+
69
+ test("整条 report_status 负载(不只是 host 子对象)也接受 ip=null", () => {
70
+ const full = z.object(schemas.report_status);
71
+ expect(() => full.parse({
72
+ resume_id: "probe-resume", alias: "probe-node", status: "idle",
73
+ host: { hostname: "unknown", ip: null },
74
+ })).not.toThrow();
75
+ });
76
+ });
77
+
78
+ /* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
79
+ *
80
+ * 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
81
+ * 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
82
+ * 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
83
+ * 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
84
+ *
85
+ * 消毒在读取处(`/api/host-supervisors`),不在这里。 */
86
+ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
87
+ const caps = () => schemas.report_status.config_snapshot;
88
+
89
+ test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
90
+ expect(caps()).toBeTruthy();
91
+ const parsed = caps().parse({
92
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
93
+ daemon_capabilities: { can_create_nodes: true },
94
+ });
95
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
96
+ });
97
+
98
+ test("正常值:透传", () => {
99
+ const parsed = caps().parse({
100
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
101
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
102
+ });
103
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
104
+ });
105
+
106
+ test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
107
+ const parsed = caps().parse({
108
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
109
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
110
+ });
111
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
112
+ });
113
+
114
+ /* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
115
+ * 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
116
+ test.each([
117
+ ["负数", -1],
118
+ ["非整数", 1234.5678],
119
+ ["超过一年", 400 * 24 * 60 * 60 * 1000],
120
+ ["NaN", Number.NaN],
121
+ ])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
122
+ expect(() => caps().parse({
123
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
124
+ daemon_capabilities: { can_create_nodes: false,
125
+ create_nodes_blocked_reason: "anet_bin_source",
126
+ create_capability_observed_ms_ago: value },
127
+ })).not.toThrow();
128
+ });
129
+
130
+ /* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
131
+ * 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
132
+ * null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
133
+ test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
134
+ const parsed = caps().parse({
135
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
136
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
137
+ });
138
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
139
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
140
+ });
141
+ });
142
+
143
+ /* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
144
+ *
145
+ * 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
146
+ * hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
147
+ * strict 拒掉整份 report(节点当场失联,#1225 的形状)。
148
+ *
149
+ * 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
150
+ * 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
151
+ * 而不是「必须是某个值」。 */
152
+ describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
153
+ const rs = () => schemas.report_status;
154
+ const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
155
+
156
+ function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
157
+ try {
158
+ const parsed = schema.parse({ ...base, ...UNKNOWN });
159
+ return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
160
+ } catch { return "rejected"; }
161
+ }
162
+
163
+ const SIDE_THREAD = {
164
+ supported: true, runtime: "codex", runtimeVersion: "1.0",
165
+ topology: "native-exact-fork", evidenceRevision: "r1",
166
+ };
167
+ const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
168
+
169
+ test.each([
170
+ ["host", () => rs().host, {}, "dropped"],
171
+ ["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
172
+ ["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
173
+ ["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
174
+ ])("%s → 未知键被 %s", (_name, get, base, expected) => {
175
+ expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
176
+ });
177
+
178
+ test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
179
+ const parsed: any = rs().config_snapshot.parse({
180
+ ...SNAP_BASE,
181
+ daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
182
+ });
183
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
184
+ expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
185
+ });
186
+
187
+ test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
188
+ expect(() => rs().config_snapshot.parse({
189
+ ...SNAP_BASE,
190
+ side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
191
+ })).toThrow();
192
+ // 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
193
+ // 和 strict 无关。
194
+ expect(() => rs().config_snapshot.parse({
195
+ ...SNAP_BASE, side_thread_capability: SIDE_THREAD,
196
+ })).not.toThrow();
197
+ });
198
+
199
+ /* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
200
+ * 那段注释描述的「不对称」就不存在了,而这条会先红。 */
201
+ test("这张表确实是不对称的(两种后果各至少一个)", () => {
202
+ const results = [
203
+ acceptsUnknownKey(rs().host, {}),
204
+ acceptsUnknownKey(rs().process_telemetry, {}),
205
+ acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
206
+ acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
207
+ ];
208
+ expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
209
+ expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
210
+ });
211
+ });
@@ -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,16 @@
1
+ /** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
2
+ export function normalizeTags(value: unknown): string[] | null {
3
+ if (!Array.isArray(value) || value.length > 10) return null;
4
+ const tags: string[] = [];
5
+ for (const valueTag of value) {
6
+ if (typeof valueTag !== "string") return null;
7
+ const tag = valueTag.trim();
8
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
9
+ if (!tags.includes(tag)) tags.push(tag);
10
+ }
11
+ return tags;
12
+ }
13
+
14
+ export function storedTags(value: string | null): string[] {
15
+ try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
16
+ }
@@ -0,0 +1,91 @@
1
+ // GET /api/requirements conditional requests: ETag on every list response, 304 with an empty body
2
+ // when If-None-Match matches. The app polls the whole list every 15 s (production: 500 rows,
3
+ // 708 KB / 163 KB gzip) and it is unchanged on almost every poll. Old clients never send
4
+ // If-None-Match and keep getting the full 200.
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const dir = mkdtempSync(join(tmpdir(), "anet-requirements-etag-"));
11
+ process.env.COMMHUB_DB = join(dir, "hub.db");
12
+
13
+ let server: { port: number; stop?: (force?: boolean) => void };
14
+ let base = "";
15
+ let ownerToken = "", network = "";
16
+
17
+ const list = (token: string, headers: Record<string, string> = {}) =>
18
+ fetch(`${base}/api/requirements?network_id=${network}`, { headers: { Authorization: `Bearer ${token}`, ...headers } });
19
+
20
+ beforeAll(async () => {
21
+ const { register } = await import("./auth.js");
22
+ const owner = register(`etag_owner_${Date.now()}`, "EtagOwnerPassw0rd!", undefined, "seed");
23
+ ownerToken = owner.token!; network = owner.network_id!;
24
+ const mod: any = await import("./server.js");
25
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
26
+ base = `http://127.0.0.1:${server.port}`;
27
+ const created = await fetch(`${base}/api/requirements`, {
28
+ method: "POST",
29
+ headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
30
+ body: JSON.stringify({ name: "etag card", network_id: network }),
31
+ });
32
+ expect(created.status).toBe(201);
33
+ }, 30_000);
34
+
35
+ afterAll(() => {
36
+ try { server?.stop?.(true); } catch {}
37
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
38
+ });
39
+
40
+ describe("GET /api/requirements conditional requests", () => {
41
+ test("a plain GET is the same 200 JSON as before, plus ETag and private/no-cache", async () => {
42
+ const res = await list(ownerToken);
43
+ expect(res.status).toBe(200);
44
+ expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
45
+ expect(res.headers.get("etag")).toMatch(/^W\/"[A-Za-z0-9_-]{27}"$/);
46
+ expect(res.headers.get("cache-control")).toBe("private, no-cache");
47
+ const body = await res.json() as any;
48
+ expect(body.ok).toBe(true);
49
+ expect(body.requirements.map((r: any) => r.name)).toEqual(["etag card"]);
50
+ expect(Array.isArray(body.capabilities)).toBe(true);
51
+ });
52
+
53
+ test("If-None-Match with the current ETag → 304, empty body, same ETag", async () => {
54
+ const etag = (await list(ownerToken)).headers.get("etag")!;
55
+ const res = await list(ownerToken, { "If-None-Match": etag });
56
+ expect(res.status).toBe(304);
57
+ expect(res.headers.get("etag")).toBe(etag);
58
+ expect(await res.text()).toBe("");
59
+ // strong/weak spelling and lists are accepted
60
+ expect((await list(ownerToken, { "If-None-Match": etag.replace(/^W\//, "") })).status).toBe(304);
61
+ expect((await list(ownerToken, { "If-None-Match": `W/"stale", ${etag}` })).status).toBe(304);
62
+ });
63
+
64
+ test("304 survives gzip negotiation (not compressed, still empty)", async () => {
65
+ const etag = (await list(ownerToken, { "Accept-Encoding": "gzip" })).headers.get("etag")!;
66
+ const res = await list(ownerToken, { "If-None-Match": etag, "Accept-Encoding": "gzip" });
67
+ expect(res.status).toBe(304);
68
+ expect(res.headers.get("content-encoding")).toBeNull();
69
+ });
70
+
71
+ test("any change to the list changes the ETag → full 200 again", async () => {
72
+ const before = await list(ownerToken);
73
+ const etag = before.headers.get("etag")!;
74
+ const id = ((await before.json()) as any).requirements[0].id;
75
+ const patched = await fetch(`${base}/api/requirements/${id}?network_id=${network}`, {
76
+ method: "PATCH",
77
+ headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
78
+ body: JSON.stringify({ name: "etag card renamed" }),
79
+ });
80
+ expect(patched.status).toBe(200);
81
+ const res = await list(ownerToken, { "If-None-Match": etag });
82
+ expect(res.status).toBe(200);
83
+ expect(res.headers.get("etag")).not.toBe(etag);
84
+ expect(((await res.json()) as any).requirements[0].name).toBe("etag card renamed");
85
+ });
86
+
87
+ test("a stale or foreign ETag never yields 304", async () => {
88
+ expect((await list(ownerToken, { "If-None-Match": 'W/"not-the-current-one"' })).status).toBe(200);
89
+ expect((await list(ownerToken, { "If-None-Match": "" })).status).toBe(200);
90
+ });
91
+ });