@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) 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-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -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,139 @@
1
+ import './require-explicit-test-db';
2
+ import { afterAll, beforeEach, expect, test } from 'bun:test';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { db } from './db';
7
+ import { registerTools } from './tools';
8
+ import { vaultGet } from './vault';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { reconcileProviderSnapshots, secureProviderTransport } from '../../agent-node/src/runtime/daemon-provider-snapshot';
11
+ import { DaemonProviderStore } from '../../agent-network/src/daemon-provider-store';
12
+ const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
13
+ const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
14
+ function cleanup() {
15
+ for (const t of ['daemon_provider_writes', 'daemon_provider_applications', 'daemon_provider_logins']) db.run(`DELETE FROM ${t} WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)`, [net]);
16
+ for (const t of ['hub_provider_bindings', 'hub_provider_configs', 'network_secrets', 'daemon_provider_snapshots', 'nodes', 'api_tokens', 'network_members']) db.run(`DELETE FROM ${t} WHERE network_id=?1`, [net]);
17
+ db.run('DELETE FROM networks WHERE network_id=?1', [net]); db.run('DELETE FROM users WHERE user_id=?1', [user]);
18
+ }
19
+ beforeEach(() => {
20
+ cleanup(); rmSync(join(root, '.anet'), { recursive: true, force: true });
21
+ process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
22
+ db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [user]);
23
+ db.run('INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)', [net, user]);
24
+ db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [user, net]);
25
+ db.run('INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot) VALUES(?1,?2,?2,?3,?4)', [daemonId, alias, net, JSON.stringify({ role: 'host_supervisor' })]);
26
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)", [token, user, net, `node:${alias}`, `hash_${token}`]);
27
+ const dir = join(root, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', node_id: daemonId, network_id: net }), { mode: 0o600 });
29
+ });
30
+ afterAll(() => { cleanup(); rmSync(root, { recursive: true, force: true }); });
31
+ function client(kind = 'human') {
32
+ const handlers: Record<string, any> = {}, server = new McpServer({ name: 'test907', version: '0' }) as any;
33
+ const tool = server.tool.bind(server), register = server.registerTool.bind(server);
34
+ server.tool = (name: string, desc: any, shape: any, handler: any) => { handlers[name] = handler; return tool(name, desc, shape, handler); };
35
+ server.registerTool = (name: string, config: any, handler: any) => { handlers[name] = handler; return register(name, config, handler); };
36
+ registerTools(server, undefined, kind === 'daemon' ? net : null, kind === 'anonymous' ? null : user, alias, kind === 'daemon', kind === 'daemon' ? token : null);
37
+ return async (name: string, args: any = {}) => JSON.parse((await handlers[name](args)).content[0].text);
38
+ }
39
+ const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
40
+ const save = (human: ReturnType<typeof client>, value = provider(), revision = 0) => human('daemon_provider_snapshot', { action: 'hub_put', id: value.id, network_id: net, revision, provider: value });
41
+ const bind = (human: ReturnType<typeof client>, target = daemonId, revision = 1) => human('daemon_provider_snapshot', { action: 'hub_bind', network_id: net, id: 'shared', revision, provider: { daemon_node_id: target, daemon_revision: 0, confirm: true } });
42
+ test('HTTP Hub and API endpoint support save, explicit bind and exact private-store readback', async () => {
43
+ expect(secureProviderTransport('http://hub907.example.test')).toBe(true);
44
+ expect(secureProviderTransport('ftp://hub907.example.test')).toBe(false);
45
+ expect(secureProviderTransport('http://user:password@hub907.example.test')).toBe(false);
46
+ const human = client(), daemon = client('daemon');
47
+ const config = { ...provider(), baseUrl: 'http://provider.example/v1' };
48
+ const saved = await save(human, config); expect(saved.ok).toBe(true);
49
+ expect(saved.provider.baseUrl).toBe(config.baseUrl);
50
+ const queued = await bind(human); expect(queued.ok).toBe(true);
51
+ await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId,
52
+ serverUrl: 'http://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
53
+ const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
54
+ expect(receipt.status).toBe('succeeded');
55
+ expect(receipt.snapshot.providers[0].runtimes[0].auth[0].baseUrl).toBe(config.baseUrl);
56
+ expect(JSON.stringify([saved, queued, receipt])).not.toContain(key);
57
+ const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
58
+ await store.withApiKey({ revision: 1, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async actual => {
59
+ expect(actual.baseUrl).toBe(config.baseUrl); expect(actual.key).toBe(key);
60
+ }); // Exact synthetic key in local callback, no external model request.
61
+ for (const baseUrl of ['ftp://provider.example/v1', 'http://user:pass@provider.example/v1', 'http://provider.example/v1?key=secret']) {
62
+ expect(await save(human, { ...provider(), id: 'invalid', baseUrl })).toMatchObject({ ok: false, error: 'invalid_hub_provider' });
63
+ }
64
+ });
65
+ test('existing names-only vault key → Provider reference → actual daemon store, without re-entering key', async () => {
66
+ const human = client(), daemon = client('daemon');
67
+ expect(await human('upsert_network_secret', { network_id: net, key: 'SHARED_API_KEY', value: key })).toMatchObject({ ok: true });
68
+ const { key: ignored, ...config } = provider();
69
+ const saved = await save(human, { ...config, secretKeyRef: 'SHARED_API_KEY' } as any);
70
+ expect(saved.provider).toMatchObject({ secret_key_ref: 'SHARED_API_KEY', credential_present: true });
71
+ expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SHARED_API_KEY');
72
+ const legacy = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net });
73
+ expect(legacy.providers[0].secret_key_ref).toBeUndefined(); // older strict client shape unchanged
74
+ const modern = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net, provider: { include_secret_refs: true } });
75
+ expect(modern.secret_reference_supported).toBe(true); expect(modern.providers[0].secret_key_ref).toBe('SHARED_API_KEY');
76
+ expect(JSON.stringify([saved, legacy, modern])).not.toContain(key);
77
+ const queued = await bind(human); expect(queued.ok).toBe(true);
78
+ await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
79
+ const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
80
+ expect(receipt.status).toBe('succeeded'); expect(receipt.snapshot.providers[0].runtimes[0].auth[0].credential_present).toBe(true);
81
+ const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
82
+ await store.withApiKey({ revision: receipt.snapshot.revision, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async profile => {
83
+ expect(profile.key).toBe(key); expect(profile.baseUrl).toBe(config.baseUrl);
84
+ }); // synthetic local callback only; no model/vendor request
85
+ expect(JSON.stringify(receipt)).not.toContain(key);
86
+ // Switching back to Provider-owned plaintext never changes the reusable named key.
87
+ await save(human, { ...provider(), key: 'TEST-ONLY-provider-specific' }, 1);
88
+ expect(vaultGet(net, 'SHARED_API_KEY')).toBe(key);
89
+ expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).not.toBe('SHARED_API_KEY');
90
+ });
91
+ test('missing or ambiguous references do not save; explicit ref replacement updates the stored reference', async () => {
92
+ const human = client(); const { key: ignored, ...config } = provider();
93
+ expect(await save(human, { ...config, secretKeyRef: 'MISSING_KEY' } as any)).toMatchObject({ ok: false, error: 'provider_secret_reference_missing' });
94
+ expect(await save(human, { ...provider(), secretKeyRef: 'SHARED_API_KEY' } as any)).toMatchObject({ ok: false, error: 'invalid_provider_secret_reference' });
95
+ await save(human);
96
+ await human('upsert_network_secret', { network_id: net, key: 'SECOND_API_KEY', value: 'TEST-ONLY-second-key' });
97
+ expect((await save(human, { ...config, secretKeyRef: 'SECOND_API_KEY' } as any, 1)).provider.secret_key_ref).toBe('SECOND_API_KEY');
98
+ expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SECOND_API_KEY');
99
+ });
100
+ test('actual Hub tool atomically saves encrypted Provider; metadata read and conflict never echo key', async () => {
101
+ const human = client(); const result = await save(human); expect(result.ok).toBe(true);
102
+ expect(result.provider).toMatchObject({ id: 'shared', revision: 1, source: 'hub', credential_present: true, compatible_runtimes: ['codex-tui'] });
103
+ const read = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net }); expect(read.providers).toHaveLength(1);
104
+ expect(JSON.stringify([result, read])).not.toContain(key);
105
+ expect(await save(human)).toMatchObject({ ok: false, error: 'hub_provider_revision_conflict' });
106
+ const secret = db.get<any>('SELECT ciphertext FROM network_secrets WHERE network_id=?1', net);
107
+ expect(Buffer.from(secret.ciphertext).toString()).not.toContain(key);
108
+ });
109
+ test('Hub → actual daemon queue → pinned CLI store → redacted receipt, not runtime application', async () => {
110
+ const human = client(), daemon = client('daemon'); await save(human);
111
+ const queued = await bind(human); expect(queued.ok).toBe(true); expect(queued.status).toBe('pending');
112
+ expect(JSON.stringify(queued)).not.toContain(key);
113
+ await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
114
+ const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
115
+ expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
116
+ expect(read.snapshot.providers[0]).toMatchObject({ id: queued.provider_reference.daemon_provider_id, label: 'Hub · Shared model' });
117
+ expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ id: 'hub_key', models: ['fixture-model'], application: 'not_applied', verification: 'not_checked' });
118
+ expect(JSON.stringify(read)).not.toContain(key);
119
+ });
120
+ test('scope/role boundaries: no node or anonymous writes, no viewer distribution or foreign daemon', async () => {
121
+ expect(await save(client('daemon'))).toMatchObject({ ok: false, error: 'user_token_required' });
122
+ expect(await save(client('anonymous'))).toMatchObject({ ok: false, error: 'user_token_required' });
123
+ const human = client(); await save(human);
124
+ expect(await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: 'foreign' })).toMatchObject({ ok: false, error: 'provider_access_denied' });
125
+ expect(await bind(human, 'foreign')).toMatchObject({ ok: false, error: 'daemon_not_found' });
126
+ db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1 AND user_id=?2", [net, user]);
127
+ expect(await bind(human)).toMatchObject({ ok: false, error: 'provider_access_denied' });
128
+ expect(await save(human)).toMatchObject({ ok: false, error: 'provider_owner_required' });
129
+ });
130
+ test('protocol mismatch never queues Codex; editing source cancels pending distribution, not installed copies', async () => {
131
+ const human = client(); await save(human, { ...provider(), protocol: 'openai-chat-completions' });
132
+ expect(await bind(human)).toMatchObject({ ok: false, error: 'runtime_protocol_not_supported' });
133
+ await save(human, provider(), 1);
134
+ const queued = await bind(human, daemonId, 2);
135
+ expect(queued.ok).toBe(true); await save(human, { ...provider(), enabled: false }, 2);
136
+ const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
137
+ expect(read).toMatchObject({ status: 'failed', error: 'hub_provider_source_changed' });
138
+ expect(db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', queued.request_id)).toBeNull();
139
+ });
@@ -0,0 +1,105 @@
1
+ // #907: real network-scoped catalog + explicit dispatch; not a Provider proxy.
2
+ import { createHash } from 'node:crypto';
3
+ import { db } from './db';
4
+ import { getUserNetworkRole } from './auth';
5
+ import { vaultUpsert, vaultGet, vaultListKeys, VaultError } from './vault';
6
+ import { requestProviderWrite } from './daemon-provider-snapshots';
7
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
8
+ type Config = { id: string; label: string; enabled: boolean; protocol: 'openai-responses' | 'openai-chat-completions' | 'anthropic-messages'; baseUrl: string; models: string[] };
9
+ const denied = (error: string) => ({ ok: false as const, error });
10
+ const idValid = (id: unknown) => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id);
11
+ function auth(h: Human, write: boolean, owner = false) {
12
+ if (h.isNode || !h.userId || !h.networkId) return denied('user_token_required');
13
+ const role = getUserNetworkRole(h.userId, h.networkId);
14
+ if (!role || (owner && role !== 'owner') || (write && !['owner', 'admin'].includes(role))) return denied(owner ? 'provider_owner_required' : 'provider_access_denied');
15
+ return null;
16
+ }
17
+ function validConfig(value: any): value is Config {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)
19
+ || Object.keys(value).some(k => !['id', 'label', 'enabled', 'protocol', 'baseUrl', 'models', 'key', 'secretKeyRef'].includes(k))
20
+ || !idValid(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
21
+ || /[\x00-\x1f\x7f]/.test(value.label) || typeof value.enabled !== 'boolean'
22
+ || !['openai-responses', 'openai-chat-completions', 'anthropic-messages'].includes(value.protocol)
23
+ || typeof value.baseUrl !== 'string' || value.baseUrl.length > 2048
24
+ || !Array.isArray(value.models) || !value.models.length || value.models.length > 200
25
+ || new Set(value.models).size !== value.models.length
26
+ || value.models.some((m: unknown) => typeof m !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(m))) return false;
27
+ try { const u = new URL(value.baseUrl); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash; }
28
+ catch { return false; }
29
+ }
30
+ type Row = { revision: number; config_json: string; secret_key_ref: string; updated_at: number };
31
+ function row(networkId: string, id: string) {
32
+ return db.get<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 AND provider_id=?2', networkId, id);
33
+ }
34
+ function publicRow(networkId: string, stored: Row, exposeRef = false) {
35
+ const config = JSON.parse(stored.config_json) as Config;
36
+ return { ...config, ...(exposeRef ? { secret_key_ref: stored.secret_key_ref } : {}), revision: stored.revision, updated_at: stored.updated_at, source: 'hub',
37
+ credential_present: vaultListKeys(networkId).includes(stored.secret_key_ref), verification: 'not_checked',
38
+ compatible_runtimes: config.protocol === 'openai-responses' ? ['codex-tui'] : [] };
39
+ }
40
+ export function readHubProviders(h: Human, exposeRefs = false) {
41
+ const refusal = auth(h, false); if (refusal) return refusal;
42
+ const rows = db.all<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 ORDER BY provider_id', h.networkId!);
43
+ return { ok: true as const, network_id: h.networkId!, source: 'hub' as const,
44
+ ...(exposeRefs ? { secret_reference_supported: true } : {}), providers: rows.map(r => publicRow(h.networkId!, r, exposeRefs)) };
45
+ }
46
+ export function putHubProvider(h: Human, revision: number, input: unknown) {
47
+ // This operation stores/rotates a shared secret: same OWNER boundary as vault writes.
48
+ const refusal = auth(h, true, true); if (refusal) return refusal;
49
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
50
+ if (!validConfig(input)) return denied('invalid_hub_provider');
51
+ const value = input as Config & { key?: string; secretKeyRef?: string };
52
+ if (value.key !== undefined && (typeof value.key !== 'string' || !value.key || value.key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(value.key))) return denied('invalid_provider_key');
53
+ if (value.secretKeyRef !== undefined && (typeof value.secretKeyRef !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(value.secretKeyRef)
54
+ || value.key !== undefined)) return denied('invalid_provider_secret_reference');
55
+ if (value.secretKeyRef && !vaultListKeys(h.networkId!).includes(value.secretKeyRef)) return denied('provider_secret_reference_missing');
56
+ const old = row(h.networkId!, value.id);
57
+ if ((old?.revision ?? 0) !== revision) return denied('hub_provider_revision_conflict');
58
+ const previous = old ? JSON.parse(old.config_json) as Config : null;
59
+ if (!value.key && !value.secretKeyRef && (!old || previous?.baseUrl !== value.baseUrl || previous?.protocol !== value.protocol)) return denied('provider_key_required');
60
+ // A new plaintext key belongs to this Provider. Never overwrite a separately
61
+ // named shared key merely because the previous config referenced it.
62
+ const privateRef = `HUB_PROVIDER_${createHash('sha256').update(JSON.stringify([h.networkId, value.id])).digest('hex').slice(0, 32).toUpperCase()}`;
63
+ const ref = value.secretKeyRef ?? (value.key ? privateRef : old!.secret_key_ref);
64
+ const { key, secretKeyRef, ...config } = value;
65
+ try {
66
+ db.transaction(() => {
67
+ if ((row(h.networkId!, value.id)?.revision ?? 0) !== revision) throw new Error('hub_provider_revision_conflict');
68
+ if (key) vaultUpsert(h.networkId!, ref, key);
69
+ db.run(`INSERT INTO hub_provider_configs(network_id,provider_id,revision,config_json,secret_key_ref,updated_at) VALUES(?1,?2,?3,?4,?5,?6)
70
+ ON CONFLICT(network_id,provider_id) DO UPDATE SET revision=excluded.revision,config_json=excluded.config_json,secret_key_ref=excluded.secret_key_ref,updated_at=excluded.updated_at`,
71
+ [h.networkId!, value.id, revision + 1, JSON.stringify(config), ref, Date.now()]);
72
+ // Cancel still-pending dispatches when the source is edited/rotated/disabled.
73
+ // Already installed copies are NOT remotely revoked by this change.
74
+ db.run(`UPDATE daemon_provider_snapshots SET status='failed',error='hub_provider_source_changed'
75
+ WHERE status='pending' AND request_id IN (SELECT request_id FROM hub_provider_bindings WHERE network_id=?1 AND provider_id=?2)`, [h.networkId!, value.id]);
76
+ db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT b.request_id FROM hub_provider_bindings b
77
+ JOIN daemon_provider_snapshots s ON s.request_id=b.request_id WHERE b.network_id=?1 AND b.provider_id=?2 AND s.status!='pending')`, [h.networkId!, value.id]);
78
+ });
79
+ return { ok: true as const, network_id: h.networkId!, provider: publicRow(h.networkId!, row(h.networkId!, value.id)!, secretKeyRef !== undefined) };
80
+ } catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_save_failed'); }
81
+ }
82
+ export function bindHubProvider(h: Human, input: { id: string; revision: number; daemon_node_id: string; daemon_revision: number; confirm: boolean }) {
83
+ const refusal = auth(h, true); if (refusal) return refusal;
84
+ if (!input || !idValid(input.id) || input.confirm !== true || !Number.isSafeInteger(input.revision)
85
+ || !Number.isSafeInteger(input.daemon_revision) || input.daemon_revision < 0) return denied('invalid_hub_provider_binding');
86
+ const stored = row(h.networkId!, input.id);
87
+ if (!stored || stored.revision !== input.revision) return denied('hub_provider_revision_conflict');
88
+ const config = JSON.parse(stored.config_json) as Config;
89
+ if (!config.enabled) return denied('hub_provider_disabled');
90
+ // Never silently treat Chat Completions/Anthropic as Codex Responses.
91
+ if (config.protocol !== 'openai-responses') return denied('runtime_protocol_not_supported');
92
+ const targetId = `hub_${createHash('sha256').update(JSON.stringify([h.networkId, input.id])).digest('hex').slice(0, 24)}`;
93
+ try {
94
+ const key = vaultGet(h.networkId!, stored.secret_key_ref); if (!key) return denied('provider_key_missing');
95
+ const queued = requestProviderWrite(h, input.daemon_node_id, input.daemon_revision, { id: targetId,
96
+ label: `Hub · ${config.label}`.slice(0, 100), enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
97
+ { id: 'hub_key', kind: 'api_key', models: config.models, baseUrl: config.baseUrl, key },
98
+ ] }] });
99
+ if (!queued.ok) return queued;
100
+ db.run(`INSERT INTO hub_provider_bindings(request_id,network_id,provider_id,provider_revision,daemon_node_id,daemon_provider_id,created_at)
101
+ VALUES(?1,?2,?3,?4,?5,?6,?7)`, [queued.request_id, h.networkId!, input.id, input.revision, input.daemon_node_id, targetId, Date.now()]);
102
+ return { ...queued, daemon_node_id: input.daemon_node_id,
103
+ provider_reference: { network_id: h.networkId!, id: input.id, revision: input.revision, daemon_provider_id: targetId } };
104
+ } catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_binding_failed'); }
105
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseHubTimestamp } from "./hub-timestamp";
3
+
4
+ const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
5
+ const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
6
+
7
+ describe("parseHubTimestamp (#1650)", () => {
8
+ test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
9
+ // `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
10
+ // 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
11
+ // 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
12
+ // 实测:去掉补 Z,本文件其余各条**全绿**。
13
+ const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
14
+ process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
15
+ const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
16
+ const [offset, parsed] = r.stdout.toString().trim().split(",");
17
+ // 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
18
+ expect(Number(offset)).not.toBe(0);
19
+ expect(parsed).toBe(String(TRUE_UTC));
20
+ });
21
+
22
+ test("已带 Z / ±HH:MM 的串不再追加时区", () => {
23
+ expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
24
+ expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
25
+ });
26
+
27
+ test("拿不到就返回 null,不返回一个看起来合理的数", () => {
28
+ for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
29
+ expect(parseHubTimestamp(bad as unknown)).toBeNull();
30
+ }
31
+ });
32
+
33
+ test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
34
+ // external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
35
+ // 应当立刻拿到 null 而不是一个错的时刻。
36
+ expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
37
+ });
38
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
3
+ * 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
4
+ *
5
+ * 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
6
+ * 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
7
+ *
8
+ * TZ=UTC 误差 0h
9
+ * TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
10
+ * TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
11
+ *
12
+ * 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
13
+ * sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
14
+ * external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
15
+ * 按字段名判会判错 —— 见 #1650 里的更正。
16
+ *
17
+ * 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
18
+ * 仓里也没有共享 lib。两边各自带测试。
19
+ */
20
+ export function parseHubTimestamp(raw: unknown): number | null {
21
+ if (typeof raw !== "string") return null;
22
+ const t = raw.trim();
23
+ if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
24
+ const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
25
+ const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
26
+ return Number.isFinite(ms) ? ms : null;
27
+ }