@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
package/src/rename.ts CHANGED
@@ -15,8 +15,10 @@
15
15
  import { randomUUID } from "crypto";
16
16
  import { db } from "./db";
17
17
  import { getUserNetworkRole, getNetworkMembers } from "./auth";
18
+ import { canSeeAgent, isAgentRestricted } from "./agent-access";
18
19
  import { pushEvent } from "./push";
19
20
  import { eventBus } from "./event_bus";
21
+ import { aliasControlCharacter, describeAliasControlCharacter } from "./node-alias-control";
20
22
 
21
23
  export interface RenameResult {
22
24
  ok: boolean;
@@ -24,6 +26,9 @@ export interface RenameResult {
24
26
  error?: string;
25
27
  code?: string;
26
28
  suggested?: string;
29
+ reason?: string;
30
+ char?: string;
31
+ message?: string;
27
32
  }
28
33
 
29
34
  export interface CanonicalAlias {
@@ -35,7 +40,8 @@ export interface CanonicalAlias {
35
40
 
36
41
  function hasWriteAccess(userId: string, networkId: string): boolean {
37
42
  const role = getUserNetworkRole(userId, networkId);
38
- return !!role && role !== "viewer";
43
+ // 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
44
+ return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
39
45
  }
40
46
 
41
47
  // Resolve committed alias renames (old -> new), following short chains such
@@ -109,6 +115,17 @@ export function prepareRename(
109
115
  ): RenameResult {
110
116
  if (!hasWriteAccess(userId, networkId)) return { ok: false, error: "no write access to this network" };
111
117
  if (!oldAlias || !newAlias) return { ok: false, error: "old and new alias required" };
118
+ const control = aliasControlCharacter(newAlias);
119
+ if (control !== null) {
120
+ return {
121
+ ok: false,
122
+ code: "node_alias_invalid",
123
+ error: "node_alias_invalid",
124
+ reason: "control_character",
125
+ char: control,
126
+ message: `alias cannot contain control character ${describeAliasControlCharacter(control)}`,
127
+ };
128
+ }
112
129
  if (oldAlias === newAlias) return { ok: false, error: "old and new alias are identical" };
113
130
 
114
131
  // old-alias must exist as a session in this network
@@ -214,8 +231,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
214
231
  // without this it would never receive node.renamed (#84 SSE channel fix:
215
232
  // N站马 confirmed the dashboard listens on the user channel). All members get
216
233
  // it, not just the owner, since any member's dashboard should reflect the rename.
234
+ // 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
217
235
  for (const member of getNetworkMembers(txn.network_id)) {
218
- if (member.username) pushEvent(member.username, renamedEvent, txn.network_id);
236
+ if (!member.username) continue;
237
+ if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
238
+ pushEvent(member.username, renamedEvent, txn.network_id);
219
239
  }
220
240
 
221
241
  return { ok: true, txn_id: txnId };
@@ -0,0 +1,96 @@
1
+ // f015d9d6 根因隔离:admin(dashboard/人类) 发给「无 node_id 的 MCP 会话」的任务,
2
+ // 其回执被 reply_target_mismatch 拒绝。
3
+ //
4
+ // 上报时给出的假设是「校验依赖投递绑定 ⇒ 任务还没真正 deliver 进会话时回执会被拒」。
5
+ // 本文件把两个变量分开量,一次只动一个:
6
+ // 变量 1 = 任务是否已投递(status created vs delivered,有无 SSE 订阅者)
7
+ // 变量 2 = 调用方是否显式传 alias
8
+ // 如果假设成立,变量 1 应当决定结果;如果 alias 推导才是决定因素,变量 2 才会。
9
+ import { beforeEach, afterAll, describe, expect, test } from "bun:test";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { db } from "./db.js";
12
+ import { registerTools } from "./tools.js";
13
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
14
+
15
+ const NET = "net_admin_reply_f015";
16
+ const USER = "user_admin_reply_f015";
17
+ const ADMIN = "admin-f015"; // 人类/dashboard 发起方,无 node
18
+ const AGENT = "agent-mcp-f015"; // MCP 会话:sessions 行 node_id = NULL
19
+ const AGENT_NODE = "node-agent-f015"; // token 绑定的 node(会话本身仍无 node_id)
20
+
21
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
22
+
23
+ function cleanup() {
24
+ __resetSSEClientsForTest();
25
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
26
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
27
+ }
28
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
29
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
30
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
31
+ }
32
+
33
+ function seed() {
34
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
35
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
36
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
37
+ db.run(
38
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', '{}', datetime('now'), datetime('now'), 'active')",
39
+ [AGENT_NODE, AGENT, NET],
40
+ );
41
+ // 🔴 关键夹具属性:会话行 node_id 为 NULL —— 这正是 MCP 挂载的 claude-code 会话的形状
42
+ db.run(
43
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now'), datetime('now'))",
44
+ [`cc-n_${AGENT}`, AGENT, NET],
45
+ );
46
+ db.run(
47
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
48
+ [`token-${AGENT}`, `hash-${AGENT}`, USER, NET, `node:${AGENT}`, AGENT_NODE],
49
+ );
50
+ }
51
+
52
+ function toolsFor(alias: string): Record<string, ToolHandler> {
53
+ const server = new McpServer({ name: "admin-reply-f015", version: "0" }) as any;
54
+ const tools: Record<string, ToolHandler> = {};
55
+ const original = server.tool.bind(server);
56
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
57
+ tools[name] = handler; return original(name, description, schema, handler);
58
+ };
59
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
60
+ return tools;
61
+ }
62
+
63
+ function parse(res: { content: Array<{ text: string }> }) { return JSON.parse(res.content[0].text); }
64
+
65
+ /** 造一条 admin(人类) → AGENT 的任务;delivered 控制「是否已投递」这一个变量。 */
66
+ function seedAdminTask(taskId: string, delivered: boolean) {
67
+ db.run(
68
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, content, status, priority, network_id, created_at)
69
+ VALUES (?1, ?2, NULL, ?3, NULL, 'ping', ?4, 'normal', ?5, datetime('now'))`,
70
+ [taskId, ADMIN, AGENT, delivered ? "delivered" : "created", NET],
71
+ );
72
+ }
73
+
74
+ describe("f015d9d6 — admin→无 node_id 的 MCP 会话,回执绑定", () => {
75
+ beforeEach(() => { cleanup(); seed(); });
76
+ afterAll(() => cleanup());
77
+
78
+ test("变量1:任务【未投递】+ 省略 alias(走 #1085 推导)⇒ 回执应当成功", async () => {
79
+ seedAdminTask("t-undelivered", false);
80
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-undelivered", text: "done", status: "completed" }));
81
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
82
+ });
83
+
84
+ test("变量1 对照:任务【已投递且有 SSE 订阅者】+ 省略 alias ⇒ 同样成功", async () => {
85
+ seedAdminTask("t-delivered", true);
86
+ createSSEStream(AGENT, NET);
87
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-delivered", text: "done", status: "completed" }));
88
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
89
+ });
90
+
91
+ test("变量2:显式传一个不等于任务发起方的 alias ⇒ reply_target_mismatch", async () => {
92
+ seedAdminTask("t-wrong-alias", true);
93
+ const r = parse(await toolsFor(AGENT).send_reply({ alias: "someone-else", in_reply_to: "t-wrong-alias", text: "done", status: "completed" }));
94
+ expect(r).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
95
+ });
96
+ });
@@ -0,0 +1,124 @@
1
+ // #550 — the sticky session capability flags (rules_file_capable / skills_capable /
2
+ // files_capable / logs_capable) survive a session handover.
3
+ //
4
+ // report_status for an alias that already has a row under ANOTHER resume_id takes
5
+ // the DELETE + INSERT handover path. That path carried registered_at and the
6
+ // descriptive columns, but not the capability flags, so the new row started at
7
+ // the column default 0 — although the flags are meant to be sticky (only ever set
8
+ // to 1). Board #548: a production node's MCP channel reconnected under a new
9
+ // resume_id and the rules-file / skills / files / logs doorbells went dark.
10
+ //
11
+ // Pinned through the real HTTP entrance (POST /mcp with the node's own token):
12
+ // 1. flags set on resume A are still 1 after the same alias reports resume B
13
+ // without them — and after it switches back to A.
14
+ // 2. only flags that were 1 are carried; a 0 is never promoted.
15
+ // 3. a later report omitting the flags on the same resume_id still keeps them.
16
+ // 4. a user token cannot report for the alias, so it cannot hand the flags to a
17
+ // session of its own.
18
+ //
19
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/report-status-handover-capabilities-http.test.ts
20
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
21
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { tmpdir } from "node:os";
25
+ import { createNetworkTokenForNode, register } from "./auth.js";
26
+ import { db } from "./db.js";
27
+
28
+ const dir = mkdtempSync(join(tmpdir(), "anet-handover-caps-"));
29
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
30
+ if (!activeDbPath) throw new Error("report-status-handover-capabilities requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
31
+
32
+ const stamp = Date.now();
33
+ let server: any;
34
+ let base = "";
35
+ let ownerToken = "", ownerId = "", NET = "";
36
+ type Node = { alias: string; nodeId: string; token: string };
37
+ const nodes: Record<string, Node> = {};
38
+
39
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
40
+ const res = await fetch(`${base}/mcp`, {
41
+ method: "POST",
42
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
43
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
44
+ });
45
+ const raw = await res.text();
46
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
47
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
48
+ const text = payload.result.content[0].text;
49
+ if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
50
+ return JSON.parse(text);
51
+ }
52
+
53
+ // resume_id is the sessions primary key — every alias below uses its own (cc-*-<nodeId>).
54
+ const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true, logs_capable: true };
55
+ const report = (token: string, n: Node, resume: string, extra: Record<string, unknown> = {}) =>
56
+ tool(token, "report_status", { resume_id: resume, alias: n.alias, status: "idle", agent: "claude-code", network_id: NET, ...extra });
57
+
58
+ type CapRow = { resume_id: string; rules_file_capable: number; skills_capable: number; files_capable: number; logs_capable: number };
59
+ const capRows = (alias: string) =>
60
+ db.all<CapRow>(
61
+ "SELECT resume_id, rules_file_capable, skills_capable, files_capable, logs_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
62
+ alias, NET,
63
+ ).map((r) => ({
64
+ resume_id: r.resume_id,
65
+ rules_file_capable: Number(r.rules_file_capable), skills_capable: Number(r.skills_capable),
66
+ files_capable: Number(r.files_capable), logs_capable: Number(r.logs_capable),
67
+ }));
68
+
69
+ beforeAll(async () => {
70
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
71
+ process.env.HOST = "127.0.0.1";
72
+ const owner = register(`hcap_owner_${stamp}`, "HandoverCaps123!", undefined, "seed");
73
+ ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
74
+ for (const key of ["all", "some", "user"]) {
75
+ const alias = `hcap-${key}-${stamp}`;
76
+ const nodeId = `n_hcap_${key}_${stamp}`;
77
+ const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
78
+ expect(minted.ok).toBe(true);
79
+ nodes[key] = { alias, nodeId, token: minted.token! };
80
+ }
81
+ const mod: any = await import("./server.js");
82
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
83
+ base = `http://127.0.0.1:${server.port}`;
84
+ }, 30_000);
85
+
86
+ afterAll(() => {
87
+ try { server?.stop?.(true); } catch {}
88
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
89
+ });
90
+
91
+ describe("#550 sticky capability flags survive a resume_id handover", () => {
92
+ test("all four flags carry to the new resume_id, and back again", async () => {
93
+ const n = nodes.all!;
94
+ expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
95
+ expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
96
+
97
+ // The channel reconnects under a new resume_id and reports WITHOUT the flags.
98
+ expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
99
+ expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
100
+
101
+ // Same resume_id, flags omitted again (status-only report) — still sticky.
102
+ expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
103
+ expect(capRows(n.alias)[0]).toMatchObject({ rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 });
104
+
105
+ // Two reporters taking turns (agent-node + TUI MCP): switching back keeps them too.
106
+ expect((await report(n.token, n, `cc-a-${n.nodeId}`)).ok).toBe(true);
107
+ expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
108
+ }, 20_000);
109
+
110
+ test("only flags that were 1 are carried — a 0 is never promoted", async () => {
111
+ const n = nodes.some!;
112
+ expect((await report(n.token, n, `cc-a-${n.nodeId}`, { rules_file_capable: true, logs_capable: true })).ok).toBe(true);
113
+ expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
114
+ expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 0, files_capable: 0, logs_capable: 1 }]);
115
+ }, 20_000);
116
+
117
+ test("a user token cannot report for the alias, so it cannot take over the flags", async () => {
118
+ const n = nodes.user!;
119
+ expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
120
+ const r = await report(ownerToken, n, `cc-hijack-${n.nodeId}`);
121
+ expect(r.ok).toBe(false);
122
+ expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
123
+ }, 20_000);
124
+ });
@@ -0,0 +1,235 @@
1
+ // report_status 的 host 遥测 schema 必须接受 agent-node **声明会发**的 null。
2
+ //
3
+ // 来源不是推理,是 #1225 复现容器里量到的一次真崩:`--network none` 里
4
+ // agent-node 一启动就死在
5
+ // MCP error -32602: Invalid input: expected string, received null at host.ip
6
+ // 而 register() 是 `await callCommHub("report_status", …)` 且没有 catch ——
7
+ // 于是**整个节点进程当场退出**,和 runtime 无关(opencode/claude/codex 一样)。
8
+ //
9
+ // 触发条件不是"容器",是"这台机器没有非回环 IPv4":
10
+ // agent-node 的 firstNonInternalIPv4()(host-telemetry.ts:48-59)在那种机器上
11
+ // 返回 null,而 HostTelemetry 的类型本来就写着 `ip: string | null`。
12
+ //
13
+ // 🔴 这里断言的是 **schema 本身**,不是 handler。直接调 handler 会绕过 zod ——
14
+ // 而缺陷恰恰只存在于 zod 那一层,绕过去就永远绿。
15
+ import { describe, expect, test } from "bun:test";
16
+ import { z } from "zod";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ function captureToolSchemas(): Record<string, any> {
21
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
22
+ const schemas: Record<string, any> = {};
23
+ const origTool = mcp.tool.bind(mcp);
24
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
25
+ schemas[name] = schema;
26
+ return origTool(name, desc, schema, handler);
27
+ };
28
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
29
+ return schemas;
30
+ }
31
+
32
+ const schemas = captureToolSchemas();
33
+
34
+ describe("report_status host telemetry schema", () => {
35
+ test("前提:抓到的确实是 report_status 的 host 形状", () => {
36
+ expect(schemas.report_status).toBeTruthy();
37
+ expect(schemas.report_status.host).toBeTruthy();
38
+ });
39
+
40
+ test("🔴 host.ip = null 必须被接受(没有非回环 IPv4 的机器就是发 null)", () => {
41
+ const host = schemas.report_status.host;
42
+ const parsed = host.parse({ hostname: "boxy", ip: null });
43
+ expect(parsed.ip).toBeNull();
44
+ });
45
+
46
+ test("🔴 host.hostname = null 同样被接受(与 ip 同为字符串字段,一起对齐)", () => {
47
+ const host = schemas.report_status.host;
48
+ expect(host.parse({ hostname: null, ip: "10.0.0.2" }).hostname).toBeNull();
49
+ });
50
+
51
+ test("agent-node 那条完整心跳负载整体过 schema", () => {
52
+ // 形状抄自 agent-node/src/host-telemetry.ts 的 HostTelemetry:
53
+ // 没有网络、没有 /proc 时每一项都可能是 null。
54
+ const full = {
55
+ hostname: "unknown", ip: null,
56
+ cpu_load_1min: null, cpu_cores: null,
57
+ mem_total_gb: null, mem_used_gb: null, mem_avail_gb: null,
58
+ disk_total_gb: null, disk_used_gb: null, disk_avail_gb: null,
59
+ };
60
+ expect(() => schemas.report_status.host.parse(full)).not.toThrow();
61
+ });
62
+
63
+ test("放宽的只有 null —— 类型错的值仍然被拒", () => {
64
+ const host = schemas.report_status.host;
65
+ expect(() => host.parse({ ip: 12345 })).toThrow();
66
+ expect(() => host.parse({ ip: "x".repeat(201) })).toThrow();
67
+ });
68
+
69
+ test("整条 report_status 负载(不只是 host 子对象)也接受 ip=null", () => {
70
+ const full = z.object(schemas.report_status);
71
+ expect(() => full.parse({
72
+ resume_id: "probe-resume", alias: "probe-node", status: "idle",
73
+ host: { hostname: "unknown", ip: null },
74
+ })).not.toThrow();
75
+ });
76
+ });
77
+
78
+ /* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
79
+ *
80
+ * 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
81
+ * 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
82
+ * 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
83
+ * 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
84
+ *
85
+ * 消毒在读取处(`/api/host-supervisors`),不在这里。 */
86
+ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
87
+ const caps = () => schemas.report_status.config_snapshot;
88
+
89
+ test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
90
+ expect(caps()).toBeTruthy();
91
+ const parsed = caps().parse({
92
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
93
+ daemon_capabilities: { can_create_nodes: true },
94
+ });
95
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
96
+ });
97
+
98
+ test("正常值:透传", () => {
99
+ const parsed = caps().parse({
100
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
101
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
102
+ });
103
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
104
+ });
105
+
106
+ test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
107
+ const parsed = caps().parse({
108
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
109
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
110
+ });
111
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
112
+ });
113
+
114
+ /* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
115
+ * 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
116
+ test.each([
117
+ ["负数", -1],
118
+ ["非整数", 1234.5678],
119
+ ["超过一年", 400 * 24 * 60 * 60 * 1000],
120
+ ["NaN", Number.NaN],
121
+ ])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
122
+ expect(() => caps().parse({
123
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
124
+ daemon_capabilities: { can_create_nodes: false,
125
+ create_nodes_blocked_reason: "anet_bin_source",
126
+ create_capability_observed_ms_ago: value },
127
+ })).not.toThrow();
128
+ });
129
+
130
+ /* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
131
+ * 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
132
+ * null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
133
+ test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
134
+ const parsed = caps().parse({
135
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
136
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
137
+ });
138
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
139
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
140
+ });
141
+ });
142
+
143
+ /* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
144
+ *
145
+ * 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
146
+ * hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
147
+ * strict 拒掉整份 report(节点当场失联,#1225 的形状)。
148
+ *
149
+ * 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
150
+ * 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
151
+ * 而不是「必须是某个值」。 */
152
+ describe("adoption_candidates ingest does not reject the heartbeat or keep secrets", () => {
153
+ const caps = () => schemas.report_status.config_snapshot;
154
+ const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
155
+
156
+ test("a clean row is kept, a bad sibling is dropped, and token keys are stripped", () => {
157
+ const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: [
158
+ { node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped", token: "ntok_supersecret" },
159
+ { node_id: "n_bad", alias: "x", workdir: "relative", launch_hint: "stopped" },
160
+ { node_id: "n_hint", alias: "y", workdir: "/tmp/y", launch_hint: "nope" },
161
+ ] } });
162
+ expect(parsed.daemon_capabilities.adoption_candidates.filter(Boolean)).toEqual([
163
+ { node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped" },
164
+ ]);
165
+ expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
166
+ });
167
+
168
+ test("a non-array does not reject report_status and does not keep the payload", () => {
169
+ const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: { token: "ntok_supersecret" } } });
170
+ expect(parsed.daemon_capabilities.adoption_candidates).toBeUndefined();
171
+ expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
172
+ expect(parsed.daemon_capabilities.adopt_capable).toBe(true);
173
+ });
174
+ });
175
+
176
+ describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
177
+ const rs = () => schemas.report_status;
178
+ const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
179
+
180
+ function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
181
+ try {
182
+ const parsed = schema.parse({ ...base, ...UNKNOWN });
183
+ return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
184
+ } catch { return "rejected"; }
185
+ }
186
+
187
+ const SIDE_THREAD = {
188
+ supported: true, runtime: "codex", runtimeVersion: "1.0",
189
+ topology: "native-exact-fork", evidenceRevision: "r1",
190
+ };
191
+ const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
192
+
193
+ test.each([
194
+ ["host", () => rs().host, {}, "dropped"],
195
+ ["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
196
+ ["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
197
+ ["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
198
+ ])("%s → 未知键被 %s", (_name, get, base, expected) => {
199
+ expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
200
+ });
201
+
202
+ test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
203
+ const parsed: any = rs().config_snapshot.parse({
204
+ ...SNAP_BASE,
205
+ daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
206
+ });
207
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
208
+ expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
209
+ });
210
+
211
+ test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
212
+ expect(() => rs().config_snapshot.parse({
213
+ ...SNAP_BASE,
214
+ side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
215
+ })).toThrow();
216
+ // 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
217
+ // 和 strict 无关。
218
+ expect(() => rs().config_snapshot.parse({
219
+ ...SNAP_BASE, side_thread_capability: SIDE_THREAD,
220
+ })).not.toThrow();
221
+ });
222
+
223
+ /* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
224
+ * 那段注释描述的「不对称」就不存在了,而这条会先红。 */
225
+ test("这张表确实是不对称的(两种后果各至少一个)", () => {
226
+ const results = [
227
+ acceptsUnknownKey(rs().host, {}),
228
+ acceptsUnknownKey(rs().process_telemetry, {}),
229
+ acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
230
+ acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
231
+ ];
232
+ expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
233
+ expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
234
+ });
235
+ });