@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.141

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 (341) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1119 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +679 -0
  252. package/src/scheduled-tasks.ts +885 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,152 @@
1
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { register } from "./auth.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
7
+
8
+ if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw Error("explicit fixture DB required");
9
+ let net: string, foreign: string, user: string, token: string, hub: any, base: string;
10
+ let sequence = 0;
11
+ const next = () => `n_lhs1109_${++sequence}`;
12
+ beforeAll(async () => {
13
+ const auth = register(`lhs1109_${Date.now()}`, "FixturePassword1109!", undefined, "fixture");
14
+ if (!auth.ok || !auth.token || !auth.user || !auth.network_id) throw Error("fixture registration failed");
15
+ user = auth.user.user_id; token = auth.token; net = auth.network_id;
16
+ foreign = `net_foreign_lhs1109_${Date.now()}`;
17
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [foreign, user]);
18
+ process.env.HOST = "127.0.0.1";
19
+ const { bootServer } = await import("./server.js");
20
+ hub = await bootServer({ port: 0 }); base = `http://127.0.0.1:${hub.port}`;
21
+ }, 30_000);
22
+ beforeEach(() => {
23
+ db.run("DELETE FROM api_tokens WHERE name LIKE 'node:lhs1109_%'");
24
+ db.run("DELETE FROM sessions WHERE alias LIKE 'lhs1109_%'");
25
+ db.run("DELETE FROM nodes WHERE alias LIKE 'lhs1109_%'");
26
+ });
27
+ afterAll(() => hub?.stop(true));
28
+
29
+ function node(alias: string, network = net, owner: string | null = user) {
30
+ const id = next();
31
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,config_snapshot)
32
+ VALUES(?1,?2,?2,?3,?4,'{"role":"host_supervisor"}')`, [id, `lhs1109_${alias}`, network, owner]);
33
+ return id;
34
+ }
35
+ function credential(id: string, opts: { bound?: string | null; name?: string; issuer?: string; epoch?: number; network?: string; scope?: string; expired?: boolean; revoked?: boolean } = {}) {
36
+ const n = db.get<any>("SELECT alias, network_id FROM nodes WHERE node_id=?1", id)!;
37
+ const tid = `tok_lhs1109_${++sequence}`;
38
+ db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,scope,name,bound_node_id,node_identity_epoch,expires_at,revoked_at)
39
+ VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, [tid, `hash_${tid}`, opts.issuer ?? user,
40
+ opts.network ?? n.network_id, opts.scope ?? "network", `node:${opts.name ?? n.alias}`,
41
+ opts.bound === undefined ? id : opts.bound, opts.epoch ?? 2,
42
+ opts.expired ? "2000-01-01 00:00:00" : null, opts.revoked ? "2000-01-01 00:00:00" : null]);
43
+ return tid;
44
+ }
45
+ function heartbeat(id: string, opts: { bound?: string | null; network?: string | null; alias?: string; age?: number } = {}) {
46
+ const n = db.get<any>("SELECT alias,network_id FROM nodes WHERE node_id=?1", id)!;
47
+ db.run(`INSERT INTO sessions(resume_id,alias,network_id,node_id,last_seen_at,status,cpu_cores,mem_total_gb,ip)
48
+ VALUES(?1,?2,?3,?4,?5,'idle',8,16,'10.1.1.1')`, [`s_lhs1109_${++sequence}`, opts.alias ?? n.alias,
49
+ opts.network === undefined ? n.network_id : opts.network, opts.bound === undefined ? id : opts.bound,
50
+ new Date(Date.now() - (opts.age ?? 0)).toISOString()]);
51
+ }
52
+ async function list(entry: "MCP" | "REST") {
53
+ if (entry === "REST") {
54
+ const r = await fetch(`${base}/api/host-supervisors?network_id=${net}`, { headers: { Authorization: `Bearer ${token}` } });
55
+ expect(r.status).toBe(200); return (await r.json() as any).daemons as any[];
56
+ }
57
+ const server = new McpServer({ name: "fixture", version: "0" }) as any;
58
+ let handler: any;
59
+ const original = server.tool.bind(server);
60
+ server.tool = (name: string, desc: string, schema: any, fn: any) => {
61
+ if (name === "list_host_supervisors") handler = fn;
62
+ return original(name, desc, schema, fn);
63
+ };
64
+ registerTools(server, undefined, null, user, null, false, null);
65
+ const result = await handler({ network_id: net });
66
+ const parsed = JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); return parsed.daemons as any[];
67
+ }
68
+ for (const entry of ["MCP", "REST"] as const) {
69
+ test(`${entry}: bound credentials survive stale token names and rotations dedupe`, async () => {
70
+ const id = node("renamed"); credential(id, { name: "lhs1109_oldname" }); credential(id); heartbeat(id);
71
+ const rows = await list(entry); expect(rows.length).toBe(1);
72
+ expect(rows[0]).toMatchObject({ daemon_node_id: id, online: true });
73
+ expect(JSON.stringify(rows)).not.toContain("hash_");
74
+ });
75
+ test(`${entry}: same-name foreign binding and missing bound node cannot authenticate daemon`, async () => {
76
+ const id = node("victim"), other = node("victim", foreign);
77
+ credential(id, { bound: other }); credential(id, { bound: "n_lhs1109_missing" }); heartbeat(id);
78
+ expect(await list(entry)).toEqual([]);
79
+ });
80
+ test(`${entry}: legacy owner and epoch-zero ownerless keep compatibility; squatter and new ownerless rejected`, async () => {
81
+ const good = node("owned"), old = node("old", net, null), squatter = node("squatter"), recent = node("recent", net, null);
82
+ credential(good, { bound: null }); credential(old, { bound: null, epoch: 0 });
83
+ credential(squatter, { bound: null, issuer: "u_foreign_fixture" }); credential(recent, { bound: null, epoch: 1 });
84
+ const ids = (await list(entry)).map(r => r.daemon_node_id).sort(); expect(ids).toEqual([good, old].sort());
85
+ });
86
+ test(`${entry}: same-alias sibling heartbeat cannot make stopped daemon online`, async () => {
87
+ const stopped = node("duplicate"), live = node("duplicate"); credential(stopped); credential(live); heartbeat(live);
88
+ const rows = await list(entry);
89
+ expect(rows.find(r => r.daemon_node_id === stopped)).toMatchObject({ online: false, last_seen_at: null });
90
+ expect(rows.find(r => r.daemon_node_id === live)).toMatchObject({ online: true });
91
+ });
92
+ test(`${entry}: null-ID heartbeat requires unique alias in its network`, async () => {
93
+ const unique = node("unique"), a = node("ambiguous"), b = node("ambiguous");
94
+ for (const id of [unique,a,b]) credential(id);
95
+ heartbeat(unique, { bound: null }); heartbeat(a, { bound: null });
96
+ const rows = await list(entry);
97
+ expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
98
+ expect(rows.filter(r => r.daemon_node_id !== unique).map(r => r.online)).toEqual([false,false]);
99
+ });
100
+ test(`${entry}: null-network legacy heartbeat is accepted only for globally unique aliases`, async () => {
101
+ const unique = node("globalunique"), shared = node("globalshared"); node("globalshared", foreign);
102
+ credential(unique); credential(shared);
103
+ // Fresh sessions is NOT NULL. Exercise the historical nullable shape in
104
+ // an actual temporary SQL table, without changing the production schema
105
+ // or mocking returned rows. Only the heartbeat read uses this table;
106
+ // the production node/token/scoped SQL and both HTTP/MCP paths stay real.
107
+ db.exec("CREATE TEMP TABLE lhs1109_legacy_sessions AS SELECT * FROM sessions WHERE 0=1");
108
+ for (const id of [unique, shared]) {
109
+ const n = db.get<any>("SELECT alias FROM nodes WHERE node_id=?1", id)!;
110
+ db.run(`INSERT INTO lhs1109_legacy_sessions(resume_id,alias,network_id,node_id,last_seen_at,updated_at,status)
111
+ VALUES(?1,?2,NULL,NULL,?3,?3,'idle')`, [`s_lhs1109_${++sequence}`, n.alias, new Date().toISOString()]);
112
+ }
113
+ const original = db.all.bind(db);
114
+ db.all = ((sql: string, ...params: any[]) => original(sql.replace("FROM sessions s", "FROM lhs1109_legacy_sessions s"), ...params)) as any;
115
+ try {
116
+ const rows = await list(entry);
117
+ expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
118
+ expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
119
+ } finally {
120
+ db.all = original;
121
+ db.exec("DROP TABLE lhs1109_legacy_sessions");
122
+ }
123
+ });
124
+ test(`${entry}: bound heartbeat ignores stale alias but refuses wrong network and stale timestamps`, async () => {
125
+ const renamed = node("newalias"), wrong = node("wrongnet"), stale = node("stale");
126
+ for (const id of [renamed,wrong,stale]) credential(id);
127
+ heartbeat(renamed, { alias: "lhs1109_stalealias" }); heartbeat(wrong, { network: foreign }); heartbeat(stale, { age: 360_000 });
128
+ const rows = await list(entry);
129
+ expect(rows.find(r => r.daemon_node_id === renamed).online).toBe(true);
130
+ expect(rows.filter(r => r.daemon_node_id !== renamed).map(r => r.online)).toEqual([false,false]);
131
+ });
132
+ test(`${entry}: unbound duplicate-owner disambiguation uses authoritative recent session`, async () => {
133
+ const a = node("unboundduplicate"), b = node("unboundduplicate"); credential(a, { bound: null }); heartbeat(b);
134
+ const rows = await list(entry); expect(rows.length).toBe(1); expect(rows[0]).toMatchObject({ daemon_node_id: b, online: true });
135
+ });
136
+ test(`${entry}: deleted, revoked, expired and wrong-scope credentials excluded`, async () => {
137
+ for (const [label,opts] of [["expired",{ expired:true }],["revoked",{ revoked:true }],["scope",{ scope:"full" }],["deleted",{}]] as const) {
138
+ const id = node(label), tid = credential(id, opts); heartbeat(id);
139
+ if (label === "deleted") db.run("DELETE FROM api_tokens WHERE token_id=?1", [tid]);
140
+ }
141
+ expect(await list(entry)).toEqual([]);
142
+ });
143
+ }
144
+ test("batched list performs three reads for many daemons and multiple scoped networks", () => {
145
+ for (let i=0;i<30;i++) { const id=node(`fleet${i}`, i%2 ? net : foreign); credential(id); heartbeat(id); }
146
+ const original = db.all.bind(db); let reads=0;
147
+ db.all = ((sql: string, ...params: any[]) => { reads++; return original(sql, ...params); }) as any;
148
+ try {
149
+ const rows = hostSupervisorIdentityRows("SELECT * FROM nodes WHERE network_id=?1 OR network_id=?2", [net,foreign]);
150
+ expect(rows.length).toBe(30); expect(reads).toBe(3);
151
+ } finally { db.all = original; }
152
+ });
@@ -0,0 +1,92 @@
1
+ import { db } from "./db.js";
2
+ import { resolveNodeCallerFromRows, type NodeCallerRow, type NodeCallerToken } from "./create-node.js";
3
+ import { parseDbTimestampMs } from "./db-timestamp.js";
4
+
5
+ type NodeRow = NodeCallerRow & Record<string, any>;
6
+ type SessionRow = {
7
+ node_id: string | null; alias: string; network_id: string | null;
8
+ last_seen_at: string | null; status: string | null;
9
+ cpu_cores: number | null; mem_total_gb: number | null; ip: string | null;
10
+ legacy_alias_count: number;
11
+ };
12
+ const key = (network: string, alias: string) => JSON.stringify([network, alias]);
13
+ const push = <T>(map: Map<string, T[]>, id: string, value: T) => {
14
+ const list = map.get(id) ?? []; list.push(value); map.set(id, list);
15
+ };
16
+
17
+ /** The caller supplies a scoped nodes query, never an unchecked network id.
18
+ * Three SQL reads regardless of fleet size; no token secrets are selected.
19
+ * All node roles participate in legacy alias disambiguation.
20
+ */
21
+ export function hostSupervisorIdentityRows(scopedNodesSql: string, params: any[]): NodeRow[] {
22
+ const nodes = db.all<NodeRow>(scopedNodesSql, ...params);
23
+ if (!nodes.length) return [];
24
+ const tokens = db.all<NodeCallerToken>(`
25
+ SELECT t.name, t.network_id, t.user_id, t.bound_node_id, t.node_identity_epoch
26
+ FROM api_tokens t
27
+ WHERE t.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
28
+ AND t.scope='network' AND t.revoked_at IS NULL
29
+ AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
30
+ AND t.name LIKE 'node:%'
31
+ `, ...params);
32
+ const sessions = db.all<SessionRow>(`
33
+ SELECT s.node_id, s.alias, s.network_id, s.last_seen_at, s.status,
34
+ s.cpu_cores, s.mem_total_gb, s.ip,
35
+ CASE WHEN s.network_id IS NULL AND s.node_id IS NULL THEN
36
+ (SELECT COUNT(*) FROM nodes n WHERE n.alias=s.alias)
37
+ ELSE 0 END AS legacy_alias_count
38
+ FROM sessions s
39
+ WHERE s.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
40
+ OR (s.network_id IS NULL AND (
41
+ s.alias IN (SELECT scoped.alias FROM (${scopedNodesSql}) scoped)
42
+ OR s.node_id IN (SELECT scoped.node_id FROM (${scopedNodesSql}) scoped)))
43
+ ORDER BY s.updated_at DESC
44
+ `, ...params);
45
+ const byId = new Map(nodes.map(n => [n.node_id, n]));
46
+ const byAlias = new Map<string, NodeRow[]>();
47
+ const byGlobalAlias = new Map<string, NodeRow[]>();
48
+ for (const n of nodes) {
49
+ push(byAlias, key(n.network_id, n.alias), n);
50
+ push(byGlobalAlias, n.alias, n);
51
+ }
52
+ const recent = new Map<string, string>();
53
+ for (const s of sessions) {
54
+ if (!s.network_id || !s.node_id) continue;
55
+ const id = key(s.network_id, s.alias);
56
+ if (!recent.has(id)) recent.set(id, s.node_id);
57
+ }
58
+ const credentialed = new Set<string>();
59
+ for (const t of tokens) {
60
+ if (!t.network_id) continue;
61
+ const bound = t.bound_node_id ? byId.get(t.bound_node_id) : null;
62
+ const rows = t.bound_node_id
63
+ ? (bound?.network_id === t.network_id ? [bound] : [])
64
+ : byAlias.get(key(t.network_id, t.name.slice(5))) ?? [];
65
+ const caller = resolveNodeCallerFromRows(t, rows, recent.get(key(t.network_id, t.name.slice(5))) ?? null);
66
+ if (caller.ok && caller.kind === "node") credentialed.add(caller.nodeId);
67
+ }
68
+ const heartbeat = new Map<string, SessionRow>();
69
+ for (const s of sessions) {
70
+ let node: NodeRow | undefined;
71
+ if (s.node_id) {
72
+ node = byId.get(s.node_id);
73
+ if (s.network_id !== null && node?.network_id !== s.network_id) continue;
74
+ } else if (s.network_id) {
75
+ const matches = byAlias.get(key(s.network_id, s.alias)) ?? [];
76
+ if (matches.length === 1) node = matches[0];
77
+ } else if (Number(s.legacy_alias_count) === 1) {
78
+ node = byGlobalAlias.get(s.alias)?.[0];
79
+ }
80
+ if (!node || !s.last_seen_at || !Number.isFinite(parseDbTimestampMs(s.last_seen_at))) continue;
81
+ const previous = heartbeat.get(node.node_id);
82
+ if (!previous || parseDbTimestampMs(s.last_seen_at) > parseDbTimestampMs(previous.last_seen_at!)) {
83
+ heartbeat.set(node.node_id, s);
84
+ }
85
+ }
86
+ return nodes.filter(n => credentialed.has(n.node_id)).map(n => {
87
+ const s = heartbeat.get(n.node_id);
88
+ return { ...n, session_last_seen: s?.last_seen_at ?? null, session_status: s?.status ?? null,
89
+ session_cpu_cores: s?.cpu_cores ?? null, session_mem_total_gb: s?.mem_total_gb ?? null,
90
+ session_ip: s?.ip ?? null };
91
+ });
92
+ }
@@ -0,0 +1,94 @@
1
+ import { expect, test } from "bun:test";
2
+ import { maybeGzipResponse, wantsGzip, isCompressibleResponse, trimLightTask, GZIP_MIN_BYTES, LIGHT_TASK_MAX_CHARS } from "./http-gzip";
3
+
4
+ const big = JSON.stringify({ sessions: Array.from({ length: 200 }, (_, i) => ({ alias: `节点${i}`, task: "x".repeat(50) })) });
5
+ const json = (body: string, extra: Record<string, string> = {}) =>
6
+ new Response(body, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ...extra } });
7
+ const reqWith = (ae?: string, method = "GET") =>
8
+ new Request("http://hub/api/status?light=1", { method, headers: ae === undefined ? {} : { "Accept-Encoding": ae } });
9
+
10
+ test("wantsGzip reads Accept-Encoding, including q-values", () => {
11
+ expect(wantsGzip(reqWith("gzip, deflate, br"))).toBe(true);
12
+ expect(wantsGzip(reqWith("br;q=1.0, gzip;q=0.8"))).toBe(true);
13
+ expect(wantsGzip(reqWith("identity"))).toBe(false);
14
+ expect(wantsGzip(reqWith("gzip;q=0"))).toBe(false);
15
+ expect(wantsGzip(reqWith())).toBe(false);
16
+ });
17
+
18
+ test("a large JSON body is gzipped only when the client asked, and round-trips", async () => {
19
+ const out = await maybeGzipResponse(reqWith("gzip"), json(big));
20
+ expect(out!.headers.get("Content-Encoding")).toBe("gzip");
21
+ expect(out!.headers.get("Vary")).toContain("Accept-Encoding");
22
+ const bytes = new Uint8Array(await out!.arrayBuffer());
23
+ expect(bytes.byteLength).toBeLessThan(big.length / 3);
24
+ expect(Number(out!.headers.get("Content-Length"))).toBe(bytes.byteLength);
25
+ expect(new TextDecoder().decode(Bun.gunzipSync(bytes))).toBe(big);
26
+ expect(out!.headers.get("Content-Type")).toContain("application/json");
27
+
28
+ const plain = json(big);
29
+ expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain); // untouched object
30
+ });
31
+
32
+ test("small, streamed, already-encoded, SSE, 204 and HEAD responses are left alone", async () => {
33
+ const small = json("{\"ok\":true}");
34
+ const outSmall = await maybeGzipResponse(reqWith("gzip"), small);
35
+ expect(outSmall!.headers.get("Content-Encoding")).toBeNull();
36
+ expect(await outSmall!.text()).toBe("{\"ok\":true}");
37
+ expect(GZIP_MIN_BYTES).toBe(1024);
38
+
39
+ const sse = new Response(new ReadableStream(), { headers: { "Content-Type": "text/event-stream" } });
40
+ expect(isCompressibleResponse(sse)).toBe(false);
41
+ expect(await maybeGzipResponse(reqWith("gzip"), sse)).toBe(sse);
42
+
43
+ const encoded = json(big, { "Content-Encoding": "br" });
44
+ expect(await maybeGzipResponse(reqWith("gzip"), encoded)).toBe(encoded);
45
+
46
+ const noContent = new Response(null, { status: 204 });
47
+ expect(await maybeGzipResponse(reqWith("gzip"), noContent)).toBe(noContent);
48
+
49
+ const head = json(big);
50
+ expect(await maybeGzipResponse(reqWith("gzip", "HEAD"), head)).toBe(head);
51
+
52
+ // /api/files downloads: Range replies and attachments keep their exact byte lengths (CI #509/#514 caught this)
53
+ const partial = new Response("x".repeat(4096), { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": "bytes 0-4095/8192" } });
54
+ expect(await maybeGzipResponse(reqWith("gzip"), partial)).toBe(partial);
55
+ const attachment = new Response("y".repeat(4096), { status: 200, headers: { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=\"a.txt\"" } });
56
+ expect(await maybeGzipResponse(reqWith("gzip"), attachment)).toBe(attachment);
57
+
58
+ const binary = new Response(new Uint8Array(4096), { headers: { "Content-Type": "image/png" } });
59
+ expect(await maybeGzipResponse(reqWith("gzip"), binary)).toBe(binary);
60
+
61
+ expect(await maybeGzipResponse(reqWith("gzip"), undefined)).toBeUndefined();
62
+ });
63
+
64
+ test("trimLightTask keeps a one-line preview under the cap", () => {
65
+ expect(trimLightTask(null)).toBeNull();
66
+ expect(trimLightTask(" ")).toBeNull();
67
+ expect(trimLightTask("a\n b")).toBe("a b");
68
+ const long = "字".repeat(LIGHT_TASK_MAX_CHARS + 40);
69
+ const t = trimLightTask(long)!;
70
+ expect(t.endsWith("…")).toBe(true);
71
+ expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
72
+ expect(trimLightTask("short")).toBe("short");
73
+ });
74
+
75
+ test("a response marked reusable is gzipped once per key; unmarked ones every time; different keys never share bytes", async () => {
76
+ const { markGzipReusable, gzipCacheStats, __resetGzipCacheForTest } = await import("./http-gzip");
77
+ __resetGzipCacheForTest();
78
+ const first = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
79
+ const second = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
80
+ expect(gzipCacheStats).toEqual({ hits: 1, misses: 1 });
81
+ const a = new Uint8Array(await first!.arrayBuffer());
82
+ const b = new Uint8Array(await second!.arrayBuffer());
83
+ expect(new TextDecoder().decode(Bun.gunzipSync(b))).toBe(big);
84
+ expect(Buffer.from(b).equals(Buffer.from(a))).toBe(true);
85
+ expect(Number(second!.headers.get("Content-Length"))).toBe(b.byteLength);
86
+ const other = big.replace("节点0", "节点X");
87
+ const third = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(other), "W/\"k2\""));
88
+ expect(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await third!.arrayBuffer())))).toBe(other);
89
+ await maybeGzipResponse(reqWith("gzip"), json(big));
90
+ expect(gzipCacheStats).toEqual({ hits: 1, misses: 2 });
91
+ // 没要 gzip 的客户端照旧拿原样正文,不碰缓存
92
+ const plain = markGzipReusable(json(big), "W/\"k1\"");
93
+ expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain);
94
+ });
@@ -0,0 +1,112 @@
1
+ // 2026-09-16(Vincent:「发一个消息都要被吞一段时间」「打开窗口历史很久才出来」):
2
+ // 桌面端经 RELAY 连 hub,链路实测只有 ~13 KB/s;/api/status?light=1 128 KB 要 17 s,
3
+ // 一页 20 条任务 41 KB 要 5.6 s,而 hub 从不压缩。gzip 后 128 KB → 31 KB、137 KB → 49 KB。
4
+ // 只在客户端明确 `Accept-Encoding: gzip` 时压;text/event-stream(SSE)与流式 body 一律不碰。
5
+ export const GZIP_MIN_BYTES = 1024;
6
+
7
+ const COMPRESSIBLE = /^(application\/json|text\/(plain|html|css|javascript)|application\/javascript)\b/i;
8
+
9
+ export function wantsGzip(req: Request): boolean {
10
+ const ae = req.headers.get("accept-encoding") || "";
11
+ for (const part of ae.split(",")) {
12
+ const [coding, ...params] = part.trim().split(";").map((x) => x.trim());
13
+ if (coding.toLowerCase() !== "gzip") continue;
14
+ const q = params.find((x) => /^q=/i.test(x));
15
+ if (!q) return true;
16
+ const n = Number(q.slice(2));
17
+ return Number.isFinite(n) && n > 0;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export function isCompressibleResponse(res: Response): boolean {
23
+ // 只压完整的 200:206 Partial Content / Content-Range / 附件下载(Content-Disposition)都有自己的
24
+ // 字节语义(Range 客户端按 Content-Length 数字节),压了就错 —— CI 的 /api/files Range 测试抓到的。
25
+ if (res.status !== 200 || !res.body) return false;
26
+ if (res.headers.get("content-encoding") || res.headers.get("content-range") || res.headers.get("content-disposition")) return false;
27
+ const ct = res.headers.get("content-type") || "";
28
+ if (/text\/event-stream/i.test(ct)) return false;
29
+ return COMPRESSIBLE.test(ct);
30
+ }
31
+
32
+ // 2026-09-30(连接较慢横幅):任务页每 15 s 轮询 ~1 MB 的需求表,每次都在事件循环上同步 gzip 一遍,
33
+ // 而两次轮询之间表多半没变。带内容哈希 ETag 的响应(conditionalJson)标一个键,同一份正文只压一次。
34
+ // 键就是正文的哈希,所以不同调用者拿到同一份压缩字节是安全的:字节相同 ⇔ 正文相同。
35
+ export const GZIP_CACHE_MAX_BYTES = 16 * 1024 * 1024;
36
+ const gzipKeys = new WeakMap<Response, string>();
37
+ const gzipCache = new Map<string, Uint8Array>();
38
+ let gzipCacheBytes = 0;
39
+ export const gzipCacheStats = { hits: 0, misses: 0 };
40
+
41
+ /** 标记这个响应的正文由 `key`(正文的内容哈希)唯一决定:压缩结果可以按键复用。 */
42
+ export function markGzipReusable(res: Response, key: string): Response {
43
+ gzipKeys.set(res, key);
44
+ return res;
45
+ }
46
+
47
+ function gzipCached(key: string | undefined, raw: () => Promise<Uint8Array>): Promise<{ raw: Uint8Array | null; gz: Uint8Array | null }> | { raw: null; gz: Uint8Array } {
48
+ if (key) {
49
+ const hit = gzipCache.get(key);
50
+ if (hit) {
51
+ gzipCacheStats.hits++;
52
+ gzipCache.delete(key);
53
+ gzipCache.set(key, hit); // 最近用过的挪到队尾(LRU)
54
+ return { raw: null, gz: hit };
55
+ }
56
+ }
57
+ return raw().then(bytes => {
58
+ if (bytes.byteLength < GZIP_MIN_BYTES) return { raw: bytes, gz: null };
59
+ // 可复用的正文(带键)压一次、存起来、反复发:压缩级别拉到 9 —— 多花的那点 CPU 只花一次,
60
+ // 而线上每次都少发 ~3%(#431 实测:旧节点别名解析读 29.6 KB → 28.6 KB)。不带键的照旧默认级别。
61
+ const gz = key ? Bun.gzipSync(bytes, { level: 9 }) : Bun.gzipSync(bytes);
62
+ if (key) gzipCacheStats.misses++;
63
+ if (key && gz.byteLength <= GZIP_CACHE_MAX_BYTES / 4) {
64
+ gzipCache.set(key, gz);
65
+ gzipCacheBytes += gz.byteLength;
66
+ for (const [k, v] of gzipCache) {
67
+ if (gzipCacheBytes <= GZIP_CACHE_MAX_BYTES) break;
68
+ gzipCache.delete(k);
69
+ gzipCacheBytes -= v.byteLength;
70
+ }
71
+ }
72
+ return { raw: bytes, gz };
73
+ });
74
+ }
75
+
76
+ /** Test-only. */
77
+ export function __resetGzipCacheForTest(): void {
78
+ gzipCache.clear();
79
+ gzipCacheBytes = 0;
80
+ gzipCacheStats.hits = 0;
81
+ gzipCacheStats.misses = 0;
82
+ }
83
+
84
+ /**
85
+ * Gzip a finished response when the client asked for it and the body is worth it.
86
+ * Reads the body once; callers must not have consumed it. Anything streamed or
87
+ * not compressible is returned untouched (same object).
88
+ */
89
+ export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
90
+ if (!res) return res as undefined;
91
+ if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
92
+ const got = await gzipCached(gzipKeys.get(res), async () => new Uint8Array(await res.arrayBuffer()));
93
+ if (!got.gz) {
94
+ return new Response(got.raw, { status: res.status, statusText: res.statusText, headers: res.headers });
95
+ }
96
+ const gz = got.gz;
97
+ const headers = new Headers(res.headers);
98
+ headers.set("Content-Encoding", "gzip");
99
+ headers.set("Content-Length", String(gz.byteLength));
100
+ const vary = headers.get("Vary");
101
+ headers.set("Vary", vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || "Accept-Encoding"));
102
+ return new Response(gz, { status: res.status, statusText: res.statusText, headers });
103
+ }
104
+
105
+ /** 轻量列表里的 task 只给列表预览用;全文占了 light 载荷的一半以上。 */
106
+ export const LIGHT_TASK_MAX_CHARS = 160;
107
+ export function trimLightTask(task: unknown): string | null {
108
+ if (typeof task !== "string") return null;
109
+ const t = task.replace(/\s+/g, " ").trim();
110
+ if (!t) return null;
111
+ return t.length > LIGHT_TASK_MAX_CHARS ? `${t.slice(0, LIGHT_TASK_MAX_CHARS)}…` : t;
112
+ }
@@ -0,0 +1,187 @@
1
+ // #1473 (#909) — the Hub's Skills / secrets / Provider catalogs had MCP tools
2
+ // but no HTTP read surface, so the desktop client shipped a fixture page
3
+ // (agent-network-app/src/hub-scope-demo.ts: "Hub 这些写接口还没接通 ... 不发请求").
4
+ //
5
+ // These three endpoints are read-only and user-token only. The load-bearing
6
+ // properties are:
7
+ // * a NODE token cannot enumerate the network's catalogs;
8
+ // * a plain member of another network cannot read this network's catalogs;
9
+ // * pending SkillHub submissions stay invisible unless the caller reviews;
10
+ // * NO secret value ever appears in a response body (asserted with a
11
+ // sentinel that would be trivially visible if a value were serialized).
12
+ //
13
+ // The sentinel test is deliberately byte-level on the raw response text: a
14
+ // future field like `value_preview` would pass a JSON-shape assertion while
15
+ // still leaking the secret.
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-hub-catalog-"));
22
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
23
+ // The vault encrypts on write; set the master key before db.ts/vault.ts load.
24
+ process.env.ANET_HUB_SECRET_VAULT_KEY ||= "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
25
+
26
+ let server: ReturnType<typeof Bun.serve>;
27
+ let base = "";
28
+ let ownerToken = "", strangerToken = "", nodeToken = "";
29
+ let ownerNet = "", strangerNet = "", ownerUserId = "";
30
+ const SECRET_NAME = "PROVIDER_SENTINEL_KEY";
31
+ const SECRET_VALUE = "sentinel-value-DO-NOT-LEAK-4f9a2c";
32
+
33
+ const get = async (path: string, token: string) => {
34
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
35
+ const text = await r.text();
36
+ let body: any = null;
37
+ try { body = JSON.parse(text); } catch { body = null; }
38
+ return { status: r.status, body, text };
39
+ };
40
+
41
+ beforeAll(async () => {
42
+ const { db } = await import("./db.js");
43
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
44
+ const { vaultUpsert } = await import("./vault.js");
45
+ const stamp = Date.now();
46
+
47
+ const owner = register(`hc_owner_${stamp}`, "HcOwner-Strong-1!");
48
+ const stranger = register(`hc_stranger_${stamp}`, "HcStranger-Strong-1!");
49
+ expect(owner.ok && stranger.ok).toBe(true);
50
+ ownerToken = owner.token!;
51
+ strangerToken = stranger.token!;
52
+ ownerNet = owner.network_id!;
53
+ strangerNet = stranger.network_id!;
54
+ ownerUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `hc_owner_${stamp}`)!.user_id;
55
+
56
+ // A node token in the SAME network: it must still be refused, because a node
57
+ // is not a person and may not enumerate catalogs.
58
+ const nodeTok = createNetworkTokenForNode(ownerUserId, ownerNet, "hc-node", "n_hc_test");
59
+ expect(nodeTok.ok).toBe(true);
60
+ nodeToken = nodeTok.token!;
61
+
62
+ // Published + pending skills in the owner's network.
63
+ const skill = (id: string, status: string, slug: string) => db.run(
64
+ `INSERT INTO skillhub_skills (skill_id, network_id, slug, name, description, content, content_hash, version, status, source_type, source_alias, created_at, updated_at)
65
+ VALUES (?1, ?2, ?3, ?3, 'd', '# SKILL body', 'hash-' || ?1, '1.0.0', ?4, 'node', 'hc-node', datetime('now'), datetime('now'))`,
66
+ [id, ownerNet, slug, status],
67
+ );
68
+ skill("skill_hc_pub", "published", "hc-published");
69
+ skill("skill_hc_pending", "pending", "hc-pending");
70
+
71
+ // The sentinel secret. Names may be shown; values never.
72
+ vaultUpsert(ownerNet, SECRET_NAME, SECRET_VALUE);
73
+
74
+ db.run(
75
+ `INSERT INTO hub_provider_configs (network_id, provider_id, revision, config_json, secret_key_ref, updated_at)
76
+ VALUES (?1, 'hc-provider', 3, ?2, ?3, ?4)`,
77
+ [ownerNet, JSON.stringify({ id: "hc-provider", label: "HC Provider", enabled: true, protocol: "openai-responses", baseUrl: "https://example.test/v1", models: ["m1"] }), "HC_PROVIDER_KEY", Date.now()],
78
+ );
79
+
80
+ const mod = await import("./server.js");
81
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
82
+ base = `http://127.0.0.1:${server.port}`;
83
+ });
84
+
85
+ afterAll(() => {
86
+ try { server?.stop(true); } catch {}
87
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
88
+ });
89
+
90
+ describe("GET /api/skills", () => {
91
+ test("owner sees published skills and no content body", async () => {
92
+ const r = await get(`/api/skills?network_id=${ownerNet}`, ownerToken);
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.ok).toBe(true);
95
+ expect(r.body.reviewer).toBe(true);
96
+ expect(r.body.network_id).toBe(ownerNet);
97
+ const slugs = r.body.skills.map((s: any) => s.slug);
98
+ expect(slugs).toContain("hc-published");
99
+ // Pending is opt-in even for a reviewer.
100
+ expect(slugs).not.toContain("hc-pending");
101
+ // The list is metadata only — a body field would mean SKILL.md text leaked.
102
+ for (const s of r.body.skills) expect(s.content).toBeUndefined();
103
+ });
104
+
105
+ test("reviewer may ask for pending submissions", async () => {
106
+ const r = await get(`/api/skills?network_id=${ownerNet}&include_pending=1`, ownerToken);
107
+ expect(r.status).toBe(200);
108
+ const slugs = r.body.skills.map((s: any) => s.slug);
109
+ expect(slugs).toContain("hc-pending");
110
+ });
111
+
112
+ test("a member of another network is refused", async () => {
113
+ const r = await get(`/api/skills?network_id=${ownerNet}`, strangerToken);
114
+ expect(r.status).toBe(403);
115
+ // Cross-network access is refused by REST scope resolution BEFORE the role
116
+ // check runs, so the error names the scope gate. Either way: 403, no data.
117
+ expect(String(r.body.error)).toMatch(/access denied|permission_denied/);
118
+ });
119
+
120
+ test("a node token is refused even inside its own network", async () => {
121
+ const r = await get(`/api/skills?network_id=${ownerNet}`, nodeToken);
122
+ expect(r.status).toBe(403);
123
+ expect(r.body.error).toBe("user_token_required");
124
+ });
125
+
126
+ test("include_pending is ignored for a non-reviewer of that network", async () => {
127
+ // The stranger's own network has no skills; the point is that asking for
128
+ // pending must not widen what they can see when they ask about their own.
129
+ const r = await get(`/api/skills?network_id=${strangerNet}&include_pending=1`, strangerToken);
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.reviewer).toBe(true); // they own their own network
132
+ expect(r.body.skills).toEqual([]);
133
+ });
134
+ });
135
+
136
+ describe("GET /api/secrets", () => {
137
+ test("returns key names and timestamps, never the value", async () => {
138
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, ownerToken);
139
+ expect(r.status).toBe(200);
140
+ const names = r.body.keys.map((k: any) => k.key);
141
+ expect(names).toContain(SECRET_NAME);
142
+ // <-- The load-bearing assertion: raw body text must not contain the value.
143
+ expect(r.text).not.toContain(SECRET_VALUE);
144
+ expect(r.text).not.toContain("ciphertext");
145
+ expect(r.text).not.toContain("iv");
146
+ expect(r.text).not.toContain("tag");
147
+ });
148
+
149
+ test("a member of another network is refused and learns no key names", async () => {
150
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, strangerToken);
151
+ expect(r.status).toBe(403);
152
+ expect(r.text).not.toContain(SECRET_NAME);
153
+ expect(r.text).not.toContain(SECRET_VALUE);
154
+ });
155
+
156
+ test("a node token is refused", async () => {
157
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, nodeToken);
158
+ expect(r.status).toBe(403);
159
+ expect(r.body.error).toBe("user_token_required");
160
+ });
161
+ });
162
+
163
+ describe("GET /api/providers", () => {
164
+ test("returns the registry without secret references", async () => {
165
+ const r = await get(`/api/providers?network_id=${ownerNet}`, ownerToken);
166
+ expect(r.status).toBe(200);
167
+ expect(r.body.providers).toHaveLength(1);
168
+ expect(r.body.providers[0]).toMatchObject({ id: "hc-provider", label: "HC Provider", source: "hub" });
169
+ // The vault key NAME is a hint an attacker does not need; `exposeRefs` is
170
+ // false here on purpose.
171
+ expect(r.body.providers[0].secret_key_ref).toBeUndefined();
172
+ expect(r.text).not.toContain("HC_PROVIDER_KEY");
173
+ expect(r.text).not.toContain(SECRET_VALUE);
174
+ });
175
+
176
+ test("a member of another network is refused", async () => {
177
+ const r = await get(`/api/providers?network_id=${ownerNet}`, strangerToken);
178
+ expect(r.status).toBe(403);
179
+ expect(r.text).not.toContain("hc-provider");
180
+ });
181
+
182
+ test("a node token is refused", async () => {
183
+ const r = await get(`/api/providers?network_id=${ownerNet}`, nodeToken);
184
+ expect(r.status).toBe(403);
185
+ expect(r.body.error).toBe("user_token_required");
186
+ });
187
+ });