@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,195 @@
1
+ // #622 —— daemon 逐 runtime 自检(runtime_readiness)从 report_status 一路走到
2
+ // /api/host-supervisors。三件事:
3
+ // 1. report_status 的 zod 形状收得宽:坏的 runtime_readiness 只丢它自己,
4
+ // 绝不拒掉整份 report(否则一台 daemon 发了个奇怪的诊断字段就在 hub 上失联);
5
+ // 2. 新字段会被**保留**(daemon_capabilities 不是 strict,schema 里没声明的键会被
6
+ // 静默剥掉 —— 不加 schema 就永远到不了 REST);
7
+ // 3. REST 只在 daemon 报了时输出,旧 daemon 的行里**没有**这个键;
8
+ // can_create_nodes 语义不变。
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import "./require-explicit-test-db.js";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { register, login } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
17
+
18
+ function captureToolSchemas(): Record<string, any> {
19
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
20
+ const schemas: Record<string, any> = {};
21
+ const origTool = mcp.tool.bind(mcp);
22
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
23
+ schemas[name] = schema;
24
+ return origTool(name, desc, schema, handler);
25
+ };
26
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
27
+ return schemas;
28
+ }
29
+ const schemas = captureToolSchemas();
30
+ const snapSchema = () => schemas.report_status.config_snapshot;
31
+ const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
32
+
33
+ const READY = {
34
+ ok: true, state: "ready", reason: "可以创建", version: "2.1.290",
35
+ checked_at: "2026-10-06T03:00:00.000Z", cli: "found", auth: "present", network: "reachable",
36
+ };
37
+ const MISSING = {
38
+ ok: false, state: "missing_cli", reason: "新节点的 PATH 上找不到可用的 grok 命令", checked_at: "2026-10-06T03:00:00.000Z",
39
+ cli: "missing", auth: "present", network: "skipped",
40
+ };
41
+
42
+ describe("#622 report_status schema —— runtime_readiness", () => {
43
+ test("合法值透传(没被 daemon_capabilities 的非 strict 剥掉)", () => {
44
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
45
+ can_create_nodes: true,
46
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING, "codex-sdk": { ...READY, shared_login_count: 27 } },
47
+ } });
48
+ expect(parsed.daemon_capabilities.runtime_readiness["claude-code-cli"].state).toBe("ready");
49
+ expect(parsed.daemon_capabilities.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
50
+ expect(parsed.daemon_capabilities.runtime_readiness["codex-sdk"].shared_login_count).toBe(27);
51
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
52
+ });
53
+
54
+ test("🔴 坏形状绝不拒整份 report —— 只丢这一格", () => {
55
+ for (const bad of [
56
+ "string", 42, null, [], { x: "notobj" },
57
+ Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`rt${i}`, READY])),
58
+ ]) {
59
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: bad } });
60
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
61
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ test("单个字段坏:schema 不拒整份;读取侧消毒把它兜成 unknown", () => {
66
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
67
+ can_create_nodes: true,
68
+ runtime_readiness: { a: { ok: "yes", state: 7, reason: "x".repeat(5000), shared_login_count: -3 } },
69
+ } });
70
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
71
+ const a = sanitizeRuntimeReadiness(parsed.daemon_capabilities.runtime_readiness)!.a;
72
+ expect(a.ok).toBe(false);
73
+ expect(a.state).toBe("unknown");
74
+ expect(a.reason.length).toBe(600);
75
+ expect(a.shared_login_count).toBeUndefined();
76
+ });
77
+
78
+ test("总字节超 16 KB:整格丢掉(异常 daemon 不能往库里塞大对象)", () => {
79
+ const big = { a: { ...READY, reason: "x".repeat(20_000) } };
80
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: big } });
81
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
82
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("#622 sanitizeRuntimeReadiness(读取侧)", () => {
87
+ test("不认识的 state → unknown;ok 只有 ready 时才可能为真", () => {
88
+ const s = sanitizeRuntimeReadiness({
89
+ a: { ok: true, state: "something_new", reason: "r" },
90
+ b: { ok: true, state: "no_network", reason: "r" },
91
+ })!;
92
+ expect(s.a.state).toBe("unknown");
93
+ expect(s.a.ok).toBe(false);
94
+ expect(s.b.ok).toBe(false);
95
+ });
96
+ test("没报 / 空 → undefined(调用方据此不输出键)", () => {
97
+ expect(sanitizeRuntimeReadiness(undefined)).toBeUndefined();
98
+ expect(sanitizeRuntimeReadiness({})).toBeUndefined();
99
+ expect(sanitizeRuntimeReadiness([])).toBeUndefined();
100
+ });
101
+ test("未知枚举值的 cli/auth/network 被丢掉", () => {
102
+ const s = sanitizeRuntimeReadiness({ a: { ...READY, cli: "/home/x/bin/claude", network: "maybe" } })!;
103
+ expect(s.a.cli).toBeUndefined();
104
+ expect(s.a.network).toBeUndefined();
105
+ expect(s.a.auth).toBe("present");
106
+ });
107
+ test("generation and accepted stay only on opencode-cli", () => {
108
+ const s = sanitizeRuntimeReadiness({
109
+ "opencode-cli": { ...READY, state: "unknown", ok: false, generation: "v2", accepted: false },
110
+ "claude-code-cli": { ...READY, generation: "v2", accepted: true },
111
+ })!;
112
+ expect(s["opencode-cli"].generation).toBe("v2");
113
+ expect(s["opencode-cli"].accepted).toBe(false);
114
+ expect(s["claude-code-cli"].generation).toBeUndefined();
115
+ expect(s["claude-code-cli"].accepted).toBeUndefined();
116
+ const lone = sanitizeRuntimeReadiness({ "opencode-cli": { ...READY, accepted: true, generation: "nope" } })!;
117
+ expect(lone["opencode-cli"].generation).toBeUndefined();
118
+ expect(lone["opencode-cli"].accepted).toBeUndefined();
119
+ });
120
+ });
121
+
122
+ let BASE = "";
123
+ let server: any = null;
124
+ let userToken = "";
125
+ let userNetworkId = "";
126
+ let userId = "";
127
+
128
+ beforeAll(async () => {
129
+ process.env.HOST = "127.0.0.1";
130
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
131
+ const password = "BootstrapPw123Aa!";
132
+ const seed = register(`rr_seed_${suffix}`, password, undefined, "seed");
133
+ if (!seed.ok) throw new Error("seed failed");
134
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`rr_seed_${suffix}`]);
135
+ const u = register(`rr_u_${suffix}`, password, undefined, "seed");
136
+ if (!u.ok || !u.token) throw new Error("user register failed");
137
+ userToken = u.token;
138
+ userNetworkId = u.network_id ?? "";
139
+ userId = login(`rr_u_${suffix}`, password).user!.user_id;
140
+ const { bootServer } = await import("./server.js");
141
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
142
+ BASE = `http://127.0.0.1:${server.port}`;
143
+ await new Promise((r) => setTimeout(r, 100));
144
+ });
145
+
146
+ afterAll(() => {
147
+ try { server?.stop?.(true); } catch {}
148
+ });
149
+
150
+ function seedDaemon(alias: string, snapshot: object): void {
151
+ db.run(
152
+ `INSERT OR REPLACE INTO nodes (
153
+ node_id, node_name, alias, runtime, model, config_path,
154
+ channels, server, hostname, network_id, config_revision, config_snapshot
155
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
156
+ [`rr_n_${alias}`, alias, alias, "claude-agent-sdk", null, "/tmp/cfg.json", "[]", "test-host", "test-host",
157
+ userNetworkId, 0, JSON.stringify(snapshot)],
158
+ );
159
+ db.run(
160
+ `INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
161
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
162
+ [`rr_t_${alias}`, `hash_rr_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
163
+ );
164
+ }
165
+
166
+ async function listDaemons(): Promise<any[]> {
167
+ const res = await fetch(`${BASE}/api/host-supervisors`, { headers: { Authorization: `Bearer ${userToken}` } });
168
+ const body = await res.json();
169
+ if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
170
+ return body.daemons;
171
+ }
172
+
173
+ describe("#622 /api/host-supervisors 输出 runtime_readiness", () => {
174
+ test("新 daemon:逐 runtime 透传;can_create_nodes 不受影响", async () => {
175
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
176
+ seedDaemon("rr-new", { role: "host_supervisor", daemon_capabilities: {
177
+ runtimes_supported: ["claude-code-cli", "grok-build-acp"],
178
+ can_create_nodes: true,
179
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING },
180
+ } });
181
+ const d = (await listDaemons()).find((x) => x.alias === "rr-new");
182
+ expect(d.can_create_nodes).toBe(true);
183
+ expect(d.runtime_readiness["claude-code-cli"]).toEqual(READY);
184
+ expect(d.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
185
+ expect(d.runtime_readiness["grok-build-acp"].ok).toBe(false);
186
+ });
187
+
188
+ test("🔴 旧 daemon(不报):响应里没有这个键 —— 不是 {}、不是 null", async () => {
189
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
190
+ seedDaemon("rr-old", { role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], can_create_nodes: true } });
191
+ const d = (await listDaemons()).find((x) => x.alias === "rr-old");
192
+ expect(d.can_create_nodes).toBe(true);
193
+ expect("runtime_readiness" in d).toBe(false);
194
+ });
195
+ });
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { db } from "./db.js";
3
+ import { parseStoredTags } from "./node-attrs-validate.js";
3
4
 
4
5
  // Regression test for SELECT * fragility on GET /api/nodes.
5
6
  //
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
41
42
  catch { /* malformed */ }
42
43
  }
43
44
  const { config_snapshot, ...rest } = r;
44
- return { ...rest, role };
45
+ return {
46
+ ...rest,
47
+ tags: parseStoredTags(r.tags),
48
+ attrs_revision: Number(r.attrs_revision ?? 0),
49
+ role,
50
+ };
45
51
  }
46
52
 
47
53
  describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
66
72
  `SELECT node_id, node_name, alias, runtime, model,
67
73
  config_path, channels, server, hostname,
68
74
  network_id, created_at, updated_at,
69
- config_snapshot
75
+ config_snapshot, display_name, team, tags, attrs_revision
70
76
  FROM nodes WHERE node_id = ?1`,
71
77
  TEST_NODE,
72
78
  );
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
85
91
  expect(row).not.toHaveProperty("config_snapshot");
86
92
  // role is EXTRACTED from config_snapshot JSON, not the raw blob
87
93
  expect(row.role).toBe("host_supervisor");
88
- // Belt: full key set is exactly the 13 contract fields
94
+ // Belt: the key set is exactly the dashboard-facing contract.
95
+ // NOTE: this file MIRRORS the handler's SQL rather than calling it, so
96
+ // it can drift — it already lagged behind `lifecycle_state` /
97
+ // `avatar_url`, which the handler selects but this mirror does not.
98
+ // Treat a diff here as "re-read the handler", not as proof of the wire
99
+ // shape; node-attrs.test.ts asserts the real HTTP response.
89
100
  expect(Object.keys(row).sort()).toEqual([
90
- "alias", "channels", "config_path", "created_at", "hostname",
91
- "model", "network_id", "node_id", "node_name", "role", "runtime",
92
- "server", "updated_at",
101
+ "alias", "attrs_revision", "channels", "config_path", "created_at",
102
+ "display_name", "hostname", "model", "network_id", "node_id",
103
+ "node_name", "role", "runtime", "server", "tags", "team",
104
+ "updated_at",
93
105
  ]);
94
106
  });
95
107
 
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
102
114
  );
103
115
  let row = mapRow(db.get<Record<string, unknown>>(
104
116
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
105
- server, hostname, network_id, created_at, updated_at, config_snapshot
117
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
118
+ display_name, team, tags, attrs_revision
106
119
  FROM nodes WHERE node_id = ?1`,
107
120
  TEST_NODE,
108
121
  )!);
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
113
126
  db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
114
127
  row = mapRow(db.get<Record<string, unknown>>(
115
128
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
116
- server, hostname, network_id, created_at, updated_at, config_snapshot
129
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
130
+ display_name, team, tags, attrs_revision
117
131
  FROM nodes WHERE node_id = ?1`,
118
132
  TEST_NODE,
119
133
  )!);
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
124
138
  [JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
125
139
  row = mapRow(db.get<Record<string, unknown>>(
126
140
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
127
- server, hostname, network_id, created_at, updated_at, config_snapshot
141
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
142
+ display_name, team, tags, attrs_revision
128
143
  FROM nodes WHERE node_id = ?1`,
129
144
  TEST_NODE,
130
145
  )!);
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
141
156
  );
142
157
  const rows = db.all<Record<string, unknown>>(
143
158
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
144
- server, hostname, network_id, created_at, updated_at, config_snapshot
159
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
160
+ display_name, team, tags, attrs_revision
145
161
  FROM nodes WHERE network_id = ?1`,
146
162
  TEST_NETWORK,
147
163
  ).map(mapRow);
@@ -0,0 +1,294 @@
1
+ // #711 改密码只让浏览器 / app 的登录会话下线,命令行和脚本令牌默认保留。
2
+ //
3
+ // main 上 POST /api/auth/password 跑的是 `DELETE FROM api_tokens WHERE user_id = ? AND network_id IS NULL
4
+ // AND token_id != ?` —— 每台机器上 `anet login` 拿到的令牌、POST /api/auth/tokens 建的具名脚本令牌,
5
+ // 跟浏览器会话一起被删光。安全复审定的形状:默认不变,保留是显式选项。这里每条都钉住:
6
+ // · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
7
+ // · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
8
+ // · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
9
+ // · 节点 / 网络令牌永远不动;
10
+ // · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
11
+ // SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { spawnSync } from "child_process";
18
+ import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
19
+ import { db, hashToken } from "./db.js";
20
+
21
+ const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ const PW = "PwChangeKindPassw0rd!";
25
+ const PW2 = "PwChangeKindPassw0rd!2";
26
+ const CLI_LABEL = "anet 2.5.0 · test-box · login";
27
+
28
+ async function call(token: string, method: string, path: string, body?: unknown) {
29
+ const res = await fetch(`${BASE}${path}`, {
30
+ method,
31
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ });
34
+ return { status: res.status, body: await res.json().catch(() => null) as any };
35
+ }
36
+ const works = async (token: string) => (await call(token, "GET", "/api/auth/me")).status === 200;
37
+ const kindOf = (token: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_hash = ?1", hashToken(token))?.kind ?? null;
38
+
39
+ type Fleet = {
40
+ user: string; userId: string; net: string;
41
+ app: string; appB: string; regToken: string;
42
+ cliByLabel: string; cliByKind: string; apiToken: string;
43
+ nodeToken: string; netToken: string;
44
+ };
45
+
46
+ let seq = 0;
47
+ async function fleet(): Promise<Fleet> {
48
+ const user = `pwk_${Date.now()}_${++seq}`;
49
+ const reg = register(user, PW);
50
+ expect(reg.ok).toBe(true);
51
+ const login = async (extra: Record<string, unknown>) => {
52
+ const r = await call("", "POST", "/api/auth/login", { username: user, password: PW, ...extra });
53
+ expect(r.status).toBe(200);
54
+ return r.body.token as string;
55
+ };
56
+ const node = createNetworkTokenForNode(reg.user!.user_id, reg.network_id!, "pwk-node", `node_pwk_${seq}`);
57
+ expect(node.ok).toBe(true);
58
+ const api = createToken(reg.user!.user_id, "ci-script");
59
+ expect(api.ok).toBe(true);
60
+ const netTok = createToken(reg.user!.user_id, "net-script", reg.network_id!);
61
+ expect(netTok.ok).toBe(true);
62
+ return {
63
+ user, userId: reg.user!.user_id, net: reg.network_id!,
64
+ regToken: reg.token!,
65
+ app: await login({ client_label: "Desktop app" }),
66
+ appB: await login({}),
67
+ cliByLabel: await login({ client_label: CLI_LABEL }),
68
+ cliByKind: await login({ client_kind: "cli" }),
69
+ apiToken: api.token!,
70
+ nodeToken: node.token!,
71
+ netToken: netTok.token!,
72
+ };
73
+ }
74
+
75
+ beforeAll(async () => {
76
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
77
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
78
+ process.env.HOST = "127.0.0.1";
79
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
80
+ const mod: any = await import("./server.js");
81
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
82
+ BASE = `http://127.0.0.1:${hub.port}`;
83
+ }, 30_000);
84
+
85
+ afterAll(() => {
86
+ try { hub?.stop?.(true); } catch {}
87
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
88
+ });
89
+
90
+ describe("new tokens are classified at issue time", () => {
91
+ test("browser/app logins are 'login'; anet-labelled, client_kind=cli and named API tokens are 'cli'; node tokens unclassified", async () => {
92
+ const f = await fleet();
93
+ expect(kindOf(f.regToken)).toBe("login");
94
+ expect(kindOf(f.app)).toBe("login");
95
+ expect(kindOf(f.appB)).toBe("login");
96
+ expect(kindOf(f.cliByLabel)).toBe("cli");
97
+ expect(kindOf(f.cliByKind)).toBe("cli");
98
+ expect(kindOf(f.apiToken)).toBe("cli");
99
+ expect(kindOf(f.nodeToken)).toBe(null);
100
+ expect(kindOf(f.netToken)).toBe(null);
101
+ });
102
+ });
103
+
104
+ // 响应里出现任何令牌值都算泄漏:换发给调用方的那一条(body.token)除外,它本来就要交给调用方。
105
+ function assertNoTokenValues(body: any, f: Fleet) {
106
+ const { token: _rotated, ...rest } = body;
107
+ const text = JSON.stringify(rest);
108
+ for (const t of [f.regToken, f.app, f.appB, f.cliByLabel, f.cliByKind, f.apiToken, f.nodeToken, f.netToken]) {
109
+ expect(text.includes(t)).toBe(false);
110
+ }
111
+ expect(/\b(utok|atok|ntok)_[A-Za-z0-9]{8,}/.test(text)).toBe(false);
112
+ expect(text.includes("token_hash")).toBe(false);
113
+ }
114
+
115
+ describe("POST /api/auth/password (default: old apps, dashboard proxy — no field)", () => {
116
+ test("every other non-network token is revoked (pre-#711 behaviour); node tokens and the rotated current session survive", async () => {
117
+ const f = await fleet();
118
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
119
+ expect(r.status).toBe(200);
120
+ expect(r.body.ok).toBe(true);
121
+ expect(r.body.revoked_login).toBe(2); // reg + appB
122
+ expect(r.body.revoked_cli).toBe(3); // cliByLabel + cliByKind + apiToken
123
+ expect(r.body.revoked).toBe(5);
124
+ expect(r.body.kept_cli_tokens).toBeUndefined();
125
+ for (const t of [f.regToken, f.appB, f.cliByLabel, f.cliByKind, f.apiToken]) expect(await works(t)).toBe(false);
126
+ expect(await works(f.nodeToken)).toBe(true);
127
+ expect(await works(f.netToken)).toBe(true);
128
+ expect(await works(r.body.token)).toBe(true);
129
+ expect(kindOf(r.body.token)).toBe("login");
130
+ expect(await works(f.app)).toBe(false);
131
+ });
132
+
133
+ test("a client-declared cli token (client_kind / anet label) does NOT survive a default password change", async () => {
134
+ const f = await fleet();
135
+ expect(kindOf(f.cliByKind)).toBe("cli");
136
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
137
+ expect(r.status).toBe(200);
138
+ expect(await works(f.cliByKind)).toBe(false);
139
+ expect(await works(f.cliByLabel)).toBe(false);
140
+ });
141
+
142
+ test("a non-boolean keep_cli_tokens is not an opt-in; the removed revoke_cli_tokens field does nothing", async () => {
143
+ const f = await fleet();
144
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: "true", revoke_cli_tokens: false });
145
+ expect(r.status).toBe(200);
146
+ expect(r.body.revoked_cli).toBe(3);
147
+ expect(await works(f.cliByLabel)).toBe(false);
148
+ expect(await works(f.apiToken)).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("POST /api/auth/password with keep_cli_tokens=true (anet passwd)", () => {
153
+ test("only other login sessions are revoked; CLI/script tokens are kept and listed without token values", async () => {
154
+ const f = await fleet();
155
+ const r = await call(f.cliByLabel, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true });
156
+ expect(r.status).toBe(200);
157
+ expect(r.body.revoked_login).toBe(3); // reg + app + appB
158
+ expect(r.body.revoked_cli).toBe(0);
159
+ expect(r.body.revoked).toBe(3);
160
+ for (const t of [f.regToken, f.app, f.appB]) expect(await works(t)).toBe(false);
161
+ expect(await works(f.cliByKind)).toBe(true);
162
+ expect(await works(f.apiToken)).toBe(true);
163
+ expect(await works(f.nodeToken)).toBe(true);
164
+ expect(await works(f.netToken)).toBe(true);
165
+ // 当前会话:换发的新令牌可用,沿用 cli
166
+ expect(await works(r.body.token)).toBe(true);
167
+ expect(kindOf(r.body.token)).toBe("cli");
168
+ // 保留清单:不含当前这条(它已被换掉),只含另外两条 cli;字段齐全、无令牌值
169
+ const kept = r.body.kept_cli_tokens as any[];
170
+ const idOf = (t: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(t)).token_id;
171
+ expect(kept.map((k) => k.token_id).sort()).toEqual([idOf(f.cliByKind), idOf(f.apiToken)].sort());
172
+ for (const k of kept) {
173
+ expect(Object.keys(k).sort()).toEqual(["client_label", "created_at", "last_used_at", "name", "token_id"]);
174
+ expect(k.token_id).toMatch(/^tok_[0-9a-f]{12}$/);
175
+ }
176
+ expect(kept.find((k) => k.token_id === idOf(f.apiToken)).name).toBe("ci-script");
177
+ assertNoTokenValues(r.body, f);
178
+ // 可以逐条撤销
179
+ expect((await call(r.body.token, "DELETE", `/api/auth/tokens/${idOf(f.apiToken)}`)).status).toBe(200);
180
+ expect(await works(f.apiToken)).toBe(false);
181
+ });
182
+
183
+ test("default response carries no token values either", async () => {
184
+ const f = await fleet();
185
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
186
+ expect(r.status).toBe(200);
187
+ assertNoTokenValues(r.body, f);
188
+ });
189
+ });
190
+
191
+ describe("changePassword() keeps the current token row in both modes", () => {
192
+ test("no opts at all = default: CLI tokens revoked, current kept", async () => {
193
+ const f = await fleet();
194
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
195
+ const r = changePassword(f.userId, PW, PW2, cur);
196
+ expect(r.ok).toBe(true);
197
+ expect(r.revoked_cli).toBe(3);
198
+ expect(r.kept_cli_tokens).toBeUndefined();
199
+ expect(await works(f.app)).toBe(true);
200
+ expect(await works(f.cliByKind)).toBe(false);
201
+ expect(await works(f.apiToken)).toBe(false);
202
+ });
203
+
204
+ for (const keepCliTokens of [false, true]) {
205
+ test(`keepCliTokens=${keepCliTokens}: current login token kept`, async () => {
206
+ const f = await fleet();
207
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
208
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
209
+ expect(r.ok).toBe(true);
210
+ expect(await works(f.app)).toBe(true);
211
+ expect(await works(f.appB)).toBe(false);
212
+ expect(await works(f.cliByLabel)).toBe(keepCliTokens);
213
+ expect(await works(f.nodeToken)).toBe(true);
214
+ });
215
+ test(`keepCliTokens=${keepCliTokens}: current CLI token kept`, async () => {
216
+ const f = await fleet();
217
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.cliByKind)).token_id;
218
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
219
+ expect(r.ok).toBe(true);
220
+ expect(await works(f.cliByKind)).toBe(true);
221
+ expect(await works(f.app)).toBe(false);
222
+ expect(await works(f.apiToken)).toBe(keepCliTokens);
223
+ });
224
+ }
225
+ });
226
+
227
+ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
228
+ // 把 kind 列拿掉 = 回到 #711 之前的表结构;塞存量行;再起一个进程加载 db.ts(= Hub 启动迁移)。
229
+ function bootMigration() {
230
+ const r = spawnSync(process.execPath, ["-e", `await import(${JSON.stringify(join(import.meta.dir, "db.ts"))}); process.exit(0);`], {
231
+ cwd: join(import.meta.dir, ".."),
232
+ env: { ...process.env, NODE_ENV: "test" },
233
+ encoding: "utf8",
234
+ timeout: 60_000,
235
+ });
236
+ if (r.status !== 0) console.log(String(r.stderr || "").slice(-2000));
237
+ return r.status;
238
+ }
239
+
240
+ test("anet-labelled and named API tokens → cli, everything else → login, node tokens untouched; idempotent", async () => {
241
+ const f = await fleet();
242
+ db.exec("ALTER TABLE api_tokens DROP COLUMN kind");
243
+ const ins = (id: string, net: string | null, scope: string, label: string | null) =>
244
+ db.run(
245
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, 'fixture', ?5, ?6, 1)",
246
+ [id, hashToken(`fixture-${id}`), f.userId, net, scope, label],
247
+ );
248
+ const id = (s: string) => `tok_fx_${seq}_${s}`;
249
+ ins(id("cli_label"), null, "user", "anet 2.4.0 · old-box · login");
250
+ ins(id("cli_demo"), null, "user", "anet dev · unknown-host · demo sci-team");
251
+ ins(id("browser"), null, "user", "Chrome on macOS");
252
+ ins(id("nolabel"), null, "user", null);
253
+ ins(id("lookalike"), null, "user", "planet-anet dashboard");
254
+ ins(id("upper"), null, "user", "ANET 2.4.0 · box · login");
255
+ ins(id("padded"), null, "user", " anet 2.4.0 · box · login");
256
+ ins(id("named"), null, "full", null);
257
+ ins(id("node"), f.net, "network", null);
258
+ ins(id("netfull"), f.net, "full", null);
259
+
260
+ expect(bootMigration()).toBe(0);
261
+ const k = (s: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_id = ?1", id(s))?.kind ?? null;
262
+ expect(k("cli_label")).toBe("cli");
263
+ expect(k("cli_demo")).toBe("cli");
264
+ expect(k("browser")).toBe("login");
265
+ expect(k("nolabel")).toBe("login");
266
+ expect(k("lookalike")).toBe("login");
267
+ expect(k("upper")).toBe("login"); // 区分大小写:SQLite 的 LIKE 会把它当 cli,PG 不会 —— 两边必须一致
268
+ expect(k("padded")).toBe("cli"); // 与运行时一样先 trim
269
+ expect(k("named")).toBe("cli");
270
+ expect(k("node")).toBe(null);
271
+ expect(k("netfull")).toBe(null);
272
+ // fleet() 的 live 令牌也被回填(列重建后原值丢了,按同一规则算回来)
273
+ expect(kindOf(f.cliByLabel)).toBe("cli");
274
+ expect(kindOf(f.apiToken)).toBe("cli");
275
+ expect(kindOf(f.app)).toBe("login");
276
+
277
+ // 幂等:已有值不被改写(手动改成 login 的 anet 行保持 login),再跑一次结果不变
278
+ db.run("UPDATE api_tokens SET kind = 'login' WHERE token_id = ?1", [id("cli_label")]);
279
+ expect(bootMigration()).toBe(0);
280
+ expect(k("cli_label")).toBe("login");
281
+ expect(k("named")).toBe("cli");
282
+ expect(k("browser")).toBe("login");
283
+
284
+ // 迁移后的库上,keep_cli_tokens 保留回填成 cli 的存量令牌
285
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
286
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens: true });
287
+ expect(r.ok).toBe(true);
288
+ expect(k("named")).toBe("cli");
289
+ expect(k("cli_demo")).toBe("cli");
290
+ expect(k("browser")).toBe(null); // 被撤销(行已删除)
291
+ expect(k("node")).toBe(null);
292
+ expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
293
+ }, 120_000);
294
+ });