@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,327 @@
1
+ // #906: human-requested readback of ONE daemon's private Provider metadata.
2
+ // Hub registry is not a fallback. No credential values enter this protocol.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { db } from "./db";
6
+ import { getUserNetworkRole } from "./auth";
7
+ import { validateDaemonProviderSnapshot } from "./shared/daemon-provider-snapshot";
8
+ import { encryptSecret, decryptSecret, VaultError } from './vault';
9
+ import { buildControllableMap } from './node-lifecycle-controllable';
10
+ import { validProviderApplicationSelection, validProviderApplicationReceipt, type ProviderApplicationSelection } from './shared/provider-application';
11
+ import { validLoginIntent, validLoginReceipt } from './shared/daemon-provider-login';
12
+ import { validProviderCollection, type ProviderCollection } from './shared/provider-collection';
13
+
14
+ export function providerScanTargets(networkId: string, daemonId: string) {
15
+ const ownership = buildControllableMap(
16
+ db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', networkId),
17
+ db.all<{ node_id: string; daemon_node_id: string }>('SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE network_id=?1 AND status=\'active\'', networkId));
18
+ const targets = db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1 ORDER BY node_id', networkId)
19
+ .filter(n => ownership.get(n.node_id) === daemonId && typeof n.alias === 'string');
20
+ if (targets.length > 200) throw Error('inventory_limit'); // no silent incomplete denominator
21
+ return targets;
22
+ }
23
+
24
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
25
+ type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
26
+ type Row = { request_id: string; network_id: string; daemon_node_id: string; status: string;
27
+ created_at: number; expires_at: number; observed_at: number | null; snapshot_json: string | null; error: string | null };
28
+ const denied = (error: string) => ({ ok: false as const, error });
29
+ function authorize(h: Human) {
30
+ if (h.isNode || !h.userId) return denied("user_token_required");
31
+ if (!h.networkId || !["owner", "admin"].includes(getUserNetworkRole(h.userId, h.networkId) || "")) return denied("provider_admin_required");
32
+ return null;
33
+ }
34
+ function daemonExists(networkId: string, nodeId: string): boolean {
35
+ const node = db.get<{ config_snapshot: string }>(
36
+ `SELECT n.config_snapshot FROM nodes n WHERE n.node_id=?1 AND n.network_id=?2
37
+ AND EXISTS (SELECT 1 FROM api_tokens t WHERE t.network_id=n.network_id
38
+ AND t.name='node:' || n.alias AND t.revoked_at IS NULL
39
+ AND (t.expires_at IS NULL OR t.expires_at > datetime('now')))`, nodeId, networkId);
40
+ try { return JSON.parse(node?.config_snapshot || "{}").role === "host_supervisor"; } catch { return false; }
41
+ }
42
+ function expire(networkId: string, daemonId?: string) {
43
+ // Device-code instructions are short-lived; do not leave them in audit history.
44
+ db.run(`UPDATE daemon_provider_snapshots SET status='expired', snapshot_json=NULL, error='request_expired'
45
+ WHERE network_id=?1 AND request_id LIKE 'dpl_%' AND expires_at <= ?2`, [networkId, Date.now()]);
46
+ db.run(`UPDATE daemon_provider_snapshots SET status='expired', error='request_expired'
47
+ WHERE network_id=?1 AND status='pending' AND expires_at <= ?2
48
+ AND (?3 IS NULL OR daemon_node_id=?3)`, [networkId, Date.now(), daemonId ?? null]);
49
+ db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN
50
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
51
+ db.run(`DELETE FROM daemon_provider_applications WHERE request_id IN
52
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
53
+ db.run(`DELETE FROM daemon_provider_logins WHERE request_id IN
54
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
55
+ db.run(`DELETE FROM daemon_provider_snapshots WHERE network_id=?1 AND expires_at < ?2`, [networkId, Date.now() - 7 * 86400_000]);
56
+ }
57
+ /** Validate the complete public projection before encrypting the private input. */
58
+ function validateWrite(provider: any, networkId: string, daemonId: string) {
59
+ if (Buffer.byteLength(JSON.stringify(provider) || '') > 64 * 1024) throw new Error('invalid_provider');
60
+ const publicProvider = { ...provider, runtimes: provider?.runtimes?.map((r: any) => ({ ...r,
61
+ auth: r.auth?.map((a: any) => {
62
+ const { key, ...rest } = a;
63
+ if (a.kind === 'chatgpt' && key !== undefined) throw new Error('invalid_provider');
64
+ if (key !== undefined && (typeof key !== 'string' || !key || key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(key))) throw new Error('invalid_provider');
65
+ // Only CLI write fields may be supplied; public-state flags are not writable.
66
+ if (Object.keys(a).some(k => !['id','kind','models','baseUrl','key','account_id'].includes(k))) throw new Error('invalid_provider');
67
+ // A bound login write must be confirmed present by its owning daemon;
68
+ // this projection is the expected receipt, not an independent Hub claim.
69
+ return { ...rest, credential_present: a.kind === 'api_key' || (a.kind === 'chatgpt' && a.account_id !== undefined), verification: 'not_checked', application: 'not_applied' };
70
+ }),
71
+ })) };
72
+ validateDaemonProviderSnapshot({ network_id: networkId, daemon_node_id: daemonId, source: 'daemon', revision: 0, providers: [publicProvider] }, networkId, daemonId);
73
+ return publicProvider;
74
+ }
75
+ export function requestProviderWrite(h: Human, daemonId: string, expectedRevision: number, provider: unknown) {
76
+ const refusal = authorize(h); if (refusal) return refusal;
77
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
78
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || expectedRevision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
79
+ try { validateWrite(provider, h.networkId!, daemonId); } catch { return denied('invalid_provider'); }
80
+ expire(h.networkId!, daemonId);
81
+ if (db.get('SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status=\'pending\'', h.networkId!, daemonId)) return denied('provider_snapshot_busy');
82
+ try {
83
+ const enc = encryptSecret(JSON.stringify({ network_id: h.networkId, daemon_node_id: daemonId, provider }));
84
+ const requestId = `dps_${randomUUID()}`, now = Date.now();
85
+ db.transaction(() => {
86
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
87
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
88
+ db.run(`INSERT INTO daemon_provider_writes (request_id,expected_revision,ciphertext,iv,tag) VALUES (?1,?2,?3,?4,?5)`,
89
+ [requestId, expectedRevision, enc.ciphertext, enc.iv, enc.tag]);
90
+ });
91
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
92
+ } catch (error) { return denied(error instanceof VaultError ? error.code : 'provider_write_unavailable'); }
93
+ }
94
+ function privateWrite(requestId: string, networkId: string, daemonId: string) {
95
+ const row = db.get<{ expected_revision: number; ciphertext: any; iv: any; tag: any }>('SELECT * FROM daemon_provider_writes WHERE request_id=?1', requestId);
96
+ if (!row) return null;
97
+ const payload = JSON.parse(decryptSecret({ ciphertext: Buffer.from(row.ciphertext), iv: Buffer.from(row.iv), tag: Buffer.from(row.tag) }));
98
+ if (payload.network_id !== networkId || payload.daemon_node_id !== daemonId) throw new Error('scope_mismatch');
99
+ if (payload.operation === 'collect') {
100
+ if (!validProviderCollection(payload.selection)) throw Error('invalid_provider_collection');
101
+ return { expected_revision: row.expected_revision, operation: 'collect' as const, selection: payload.selection };
102
+ }
103
+ validateWrite(payload.provider, networkId, daemonId);
104
+ return { expected_revision: row.expected_revision, operation: 'put' as const, provider: payload.provider };
105
+ }
106
+ function boundCollectionTarget(networkId: string, daemonId: string, selection: ProviderCollection) {
107
+ return selection.node_id !== daemonId && providerScanTargets(networkId, daemonId)
108
+ .some(n => n.node_id === selection.node_id && n.alias === selection.alias);
109
+ }
110
+ export function requestProviderCollection(h: Human, daemonId: string, revision: number, selection: unknown) {
111
+ const refusal = authorize(h); if (refusal) return refusal;
112
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
113
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
114
+ if (!validProviderCollection(selection)) return denied('invalid_provider_collection');
115
+ try {
116
+ if (!boundCollectionTarget(h.networkId!, daemonId, selection)) return denied('collection_target_not_bound');
117
+ expire(h.networkId!, daemonId);
118
+ if (db.get("SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending'", h.networkId!, daemonId)) return denied('provider_snapshot_busy');
119
+ // Reuse the short-lived write queue, but no credential is supplied to Hub.
120
+ const enc = encryptSecret(JSON.stringify({ network_id: h.networkId, daemon_node_id: daemonId, operation: 'collect', selection }));
121
+ const requestId = `dps_${randomUUID()}`, now = Date.now();
122
+ db.transaction(() => {
123
+ db.run("INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at) VALUES (?1,?2,?3,?4,'pending',?5,?6)",
124
+ [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
125
+ db.run('INSERT INTO daemon_provider_writes (request_id,expected_revision,ciphertext,iv,tag) VALUES (?1,?2,?3,?4,?5)',
126
+ [requestId, revision, enc.ciphertext, enc.iv, enc.tag]);
127
+ });
128
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
129
+ } catch (error) { return denied(error instanceof VaultError ? error.code : 'provider_collection_unavailable'); }
130
+ }
131
+ function applicationIntent(requestId: string) {
132
+ const row = db.get<{ expected_revision: number; selection_json: string }>('SELECT * FROM daemon_provider_applications WHERE request_id=?1', requestId);
133
+ if (!row) return null;
134
+ const selection = JSON.parse(row.selection_json);
135
+ if (!validProviderApplicationSelection(selection)) throw Error('invalid_application');
136
+ return { expected_revision: row.expected_revision, selection };
137
+ }
138
+ function boundApplicationTarget(networkId: string, daemonId: string, selection: ProviderApplicationSelection) {
139
+ return selection.node_id !== daemonId && providerScanTargets(networkId, daemonId).find(n => n.node_id === selection.node_id);
140
+ }
141
+ export function requestProviderApplication(h: Human, daemonId: string, revision: number, selection: unknown) {
142
+ const refusal = authorize(h); if (refusal) return refusal;
143
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
144
+ if (!Number.isSafeInteger(revision) || revision < 0) return denied('invalid_revision');
145
+ if (!validProviderApplicationSelection(selection)) return denied('invalid_application');
146
+ try {
147
+ if (!boundApplicationTarget(h.networkId!, daemonId, selection)) return denied('application_target_not_bound');
148
+ expire(h.networkId!, daemonId);
149
+ const requestId = `dpa_${randomUUID()}`, now = Date.now();
150
+ db.transaction(() => {
151
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
152
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 600_000]);
153
+ db.run('INSERT INTO daemon_provider_applications (request_id,expected_revision,selection_json) VALUES (?1,?2,?3)',
154
+ [requestId, revision, JSON.stringify(selection)]);
155
+ });
156
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 600_000 };
157
+ } catch { return denied('provider_application_unavailable'); }
158
+ }
159
+ export function requestProviderSnapshot(h: Human, daemonId: string) {
160
+ const refusal = authorize(h); if (refusal) return refusal;
161
+ if (!daemonExists(h.networkId!, daemonId)) return denied("daemon_not_found");
162
+ expire(h.networkId!, daemonId);
163
+ // Repeated refresh clicks share the pending request; pending never means ready.
164
+ const pending = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at DESC LIMIT 1`, h.networkId!, daemonId);
165
+ if (pending) return { ok: true as const, request_id: pending.request_id, status: "pending", expires_at: pending.expires_at };
166
+ const requestId = `dps_${randomUUID()}`, now = Date.now();
167
+ db.run(`INSERT INTO daemon_provider_snapshots
168
+ (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
169
+ VALUES (?1,?2,?3,?4,'pending',?5,?6) ON CONFLICT DO NOTHING`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
170
+ const accepted = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending'`, h.networkId!, daemonId);
171
+ if (!accepted) return denied("provider_snapshot_busy");
172
+ return { ok: true as const, request_id: accepted.request_id, status: "pending", expires_at: accepted.expires_at };
173
+ }
174
+ export function requestProviderLogin(h: Human, daemonId: string, intent: unknown) {
175
+ const refusal = authorize(h); if (refusal) return refusal;
176
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
177
+ if (!validLoginIntent(intent)) return denied('invalid_login_intent');
178
+ expire(h.networkId!, daemonId);
179
+ const requestId = `dpl_${randomUUID()}`, now = Date.now();
180
+ try {
181
+ db.transaction(() => {
182
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
183
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
184
+ db.run('INSERT INTO daemon_provider_logins (request_id,intent_json) VALUES (?1,?2)', [requestId, JSON.stringify(intent)]);
185
+ });
186
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
187
+ } catch { return denied('provider_snapshot_busy'); }
188
+ }
189
+ export function getProviderSnapshot(h: Human, requestId: string, includeDiscovery = false) {
190
+ const refusal = authorize(h); if (refusal) return refusal;
191
+ expire(h.networkId!);
192
+ const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
193
+ if (!row) return denied("request_not_found");
194
+ const snapshot = !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null;
195
+ // Old clients validate an exact contract. Their default projection stays unchanged.
196
+ if (!includeDiscovery && snapshot?.codex_inventory) delete snapshot.codex_inventory.local_discovery;
197
+ return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
198
+ expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
199
+ snapshot,
200
+ ...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
201
+ ...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
202
+ }
203
+ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false, acceptCollections = false) {
204
+ if (!d.ok) return denied("caller_not_a_daemon");
205
+ if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
206
+ expire(d.networkId, d.daemonNodeId);
207
+ const requests = db.all<{ request_id: string; expires_at: number }>(`SELECT request_id,expires_at FROM daemon_provider_snapshots
208
+ WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at LIMIT 20`, d.networkId, d.daemonNodeId);
209
+ try { return { ok: true, requests: requests.flatMap<Record<string, unknown>>(r => {
210
+ if (r.request_id.startsWith('dpl_')) {
211
+ if (!acceptLogins) return [];
212
+ const row = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', r.request_id);
213
+ const intent = row && JSON.parse(row.intent_json);
214
+ if (!validLoginIntent(intent)) throw Error('invalid_login_intent');
215
+ return [{ ...r, operation: 'login', intent }];
216
+ }
217
+ if (r.request_id.startsWith('dpa_')) {
218
+ if (!acceptApplications) return []; // old daemons must not consume applications as scans
219
+ const application = applicationIntent(r.request_id);
220
+ const target = application && boundApplicationTarget(d.networkId, d.daemonNodeId, application.selection);
221
+ if (!target) {
222
+ db.run("UPDATE daemon_provider_snapshots SET status='failed',error='application_target_not_bound',observed_at=?1 WHERE request_id=?2 AND status='pending'", [Date.now(), r.request_id]);
223
+ db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [r.request_id]);
224
+ return [];
225
+ }
226
+ return [{ ...r, operation: 'apply', ...application!, alias: target.alias }];
227
+ }
228
+ const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
229
+ if (write?.operation === 'collect') {
230
+ if (!acceptCollections) return []; // old write-capable daemons cannot consume collection as put/scan
231
+ if (!boundCollectionTarget(d.networkId, d.daemonNodeId, write.selection)) {
232
+ db.run("UPDATE daemon_provider_snapshots SET status='failed',error='collection_target_not_bound',observed_at=?1 WHERE request_id=?2 AND status='pending'", [Date.now(), r.request_id]);
233
+ db.run('DELETE FROM daemon_provider_writes WHERE request_id=?1', [r.request_id]);
234
+ return [];
235
+ }
236
+ } else if (write && !acceptWrites) return [];
237
+ return [write ? { ...r, ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId), ...(acceptDiscovery ? { discover_local: true } : {}) }];
238
+ }) }; } catch { return denied('provider_write_unavailable'); }
239
+ }
240
+ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
241
+ if (!d.ok || !daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
242
+ expire(d.networkId, d.daemonNodeId);
243
+ const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2 AND daemon_node_id=?3`, requestId, d.networkId, d.daemonNodeId);
244
+ if (!row) return denied("request_not_found");
245
+ // First accepted receipt wins; reconnect replay cannot rewrite prior evidence.
246
+ if (row.status !== "pending") return { ok: row.status === "succeeded" || row.status === "failed", status: row.status };
247
+ if (requestId.startsWith('dpl_')) {
248
+ if (snapshot !== undefined || application !== undefined || appliedRevision !== undefined || (failed && login !== undefined)) return denied('invalid_login_receipt');
249
+ const intentRow = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', requestId);
250
+ const intent = intentRow && JSON.parse(intentRow.intent_json);
251
+ if (!failed && (!validLoginIntent(intent) || !validLoginReceipt(login, intent, d.networkId, d.daemonNodeId))) return denied('invalid_login_receipt');
252
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
253
+ WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(login),
254
+ failed ? 'provider_login_unavailable' : null, Date.now(), requestId]);
255
+ db.run('DELETE FROM daemon_provider_logins WHERE request_id=?1', [requestId]);
256
+ return { ok: true, status: failed ? 'failed' : 'succeeded' };
257
+ }
258
+ if (login !== undefined) return denied('invalid_login_receipt');
259
+ if (requestId.startsWith('dpa_')) {
260
+ if (snapshot !== undefined || appliedRevision !== undefined || (failed && application !== undefined)) return denied('invalid_application_receipt');
261
+ if (!failed) {
262
+ try {
263
+ const intent = applicationIntent(requestId);
264
+ if (!intent || !boundApplicationTarget(d.networkId, d.daemonNodeId, intent.selection)
265
+ || !validProviderApplicationReceipt(application, requestId, intent.expected_revision, intent.selection)
266
+ || (application as any).runtime_provider !== (intent.selection.account_id ? 'openai' : `anet_${createHash('sha256').update(JSON.stringify([intent.selection.provider_id, intent.selection.auth_id])).digest('hex').slice(0, 20)}`)
267
+ || (application as any).applied_at < row.created_at - 30_000) return denied('invalid_application_receipt');
268
+ } catch { return denied('invalid_application_receipt'); }
269
+ }
270
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
271
+ WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(application),
272
+ failed ? 'provider_application_unavailable' : null, Date.now(), requestId]);
273
+ db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [requestId]);
274
+ return { ok: true, status: failed ? 'failed' : 'succeeded' };
275
+ }
276
+ if (application !== undefined) return denied('invalid_application_receipt');
277
+ let safe: unknown = null;
278
+ if (failed && snapshot !== undefined) return denied("invalid_provider_snapshot");
279
+ if (!failed) {
280
+ try { safe = validateDaemonProviderSnapshot(snapshot, d.networkId, d.daemonNodeId); }
281
+ catch { return denied("invalid_provider_snapshot"); }
282
+ if ((safe as any).codex_inventory) {
283
+ try {
284
+ const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
285
+ const rows = (safe as any).codex_inventory.rows;
286
+ if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
287
+ const local = (safe as any).codex_inventory.local_discovery;
288
+ if (local) {
289
+ const registered = new Map(db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1', d.networkId).map(n => [n.node_id, n.alias]));
290
+ const ownership = buildControllableMap(
291
+ db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', d.networkId),
292
+ db.all<{ node_id: string; daemon_node_id: string }>("SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE network_id=?1 AND status='active'", d.networkId));
293
+ // Filesystem discovery is not control authority. Other-daemon and
294
+ // unregistered identities cannot enter this network's public receipt.
295
+ local.rows = local.rows.filter((r: any) => registered.get(r.node_id) === r.alias && !ownership.has(r.node_id));
296
+ }
297
+ } catch { return denied('invalid_provider_snapshot'); }
298
+ }
299
+ try {
300
+ const write = privateWrite(requestId, d.networkId, d.daemonNodeId);
301
+ if (write) {
302
+ const actual = safe as any;
303
+ if (appliedRevision !== write.expected_revision + 1 || actual.revision !== appliedRevision) return denied('provider_write_unconfirmed');
304
+ if (write.operation === 'collect') {
305
+ if (!boundCollectionTarget(d.networkId, d.daemonNodeId, write.selection)) return denied('collection_target_not_bound');
306
+ const saved = actual.providers.find((p: any) => p.id === write.selection.provider_id);
307
+ const auth = saved?.runtimes.find((r: any) => r.runtime === 'codex-tui')?.auth.find((a: any) => a.id === write.selection.auth_id);
308
+ if (saved?.label !== write.selection.label.trim() || auth?.kind !== 'api_key' || auth.credential_present !== true
309
+ || auth.models.length !== 1 || auth.verification !== 'not_checked' || auth.application !== 'not_applied') return denied('provider_write_unconfirmed');
310
+ } else {
311
+ const expected = validateWrite(write.provider, d.networkId, d.daemonNodeId);
312
+ const saved = actual.providers.find((p: any) => p.id === expected.id);
313
+ // CLI canonicalizes label and endpoint. No credential is compared or returned here.
314
+ expected.label = expected.label.trim();
315
+ for (const r of expected.runtimes) for (const a of r.auth) if (a.baseUrl) a.baseUrl = new URL(a.baseUrl).toString().replace(/\/$/, '');
316
+ if (!isDeepStrictEqual(saved, expected)) return denied('provider_write_unconfirmed');
317
+ }
318
+ }
319
+ } catch { return denied('provider_write_unconfirmed'); }
320
+ }
321
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1, snapshot_json=?2, error=?3, observed_at=?4
322
+ WHERE request_id=?5 AND status='pending'`, [failed ? "failed" : "succeeded", safe ? JSON.stringify(safe) : null,
323
+ failed ? "provider_snapshot_unavailable" : null, Date.now(), requestId]);
324
+ const accepted = db.get<Row>("SELECT * FROM daemon_provider_snapshots WHERE request_id=?1", requestId);
325
+ db.run('DELETE FROM daemon_provider_writes WHERE request_id=?1', [requestId]);
326
+ return { ok: true, status: accepted!.status };
327
+ }
@@ -0,0 +1,236 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
3
+ import { nodeTokenReissueError } from "../../agent-network/src/cli-errors";
4
+
5
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/owner678-")}/hub.db`;
6
+ let db: any, hub: ReturnType<typeof Bun.serve>, base: string;
7
+ let owner: any, member: any, outsider: any, legacy: string, mismatched: string, bound: string;
8
+ let wrongBound: string, ownerless: string, revoked: string, crossNetwork: string, requestId: string;
9
+ const daemon = "n_owner_fixture_daemon", child = "n_owner_fixture_child";
10
+
11
+ async function call(token: string, name: string, args: Record<string, unknown> = {}) {
12
+ const response = await fetch(`${base}/mcp`, {
13
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json",
14
+ Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
15
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
16
+ });
17
+ expect(response.status).toBe(200);
18
+ const raw = await response.text(), lines = raw.split("\n").filter(line => line.startsWith("data:"));
19
+ const message = JSON.parse(lines.length ? lines.at(-1)!.slice(5) : raw);
20
+ expect(message.error).toBeUndefined();
21
+ return JSON.parse(message.result.content[0].text);
22
+ }
23
+ async function mint(token: string, network: string, name: string, nodeId?: string) {
24
+ const response = await fetch(`${base}/api/auth/node-token`, { method: "POST",
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: JSON.stringify({ network_id: network, node_name: name, ...(nodeId ? { node_id: nodeId } : {}) }),
27
+ });
28
+ return { status: response.status, body: await response.json() as any };
29
+ }
30
+ beforeAll(async () => {
31
+ ({ db } = await import("./db.js"));
32
+ const { register } = await import("./auth.js");
33
+ const { generateNetworkToken, hashToken } = await import("./db.js");
34
+ owner = register("owner678", "Fixture-Strong1!");
35
+ member = register("member678", "Fixture-Strong1!");
36
+ outsider = register("outside678", "Fixture-Strong1!");
37
+ db.run("UPDATE users SET role='user' WHERE user_id IN (?1,?2)", [member.user.user_id, outsider.user.user_id]);
38
+ db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, member.user.user_id]);
39
+ for (const id of [daemon, child, "n_ownerless_fixture"]) {
40
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state)
41
+ VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')`, [id, owner.network_id,
42
+ id === "n_ownerless_fixture" ? null : owner.user.user_id,
43
+ JSON.stringify(id === daemon ? { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } } : {})]);
44
+ }
45
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))", [daemon, owner.network_id]);
46
+ function credential(label: string, user: string, node: string | null = null, alias = daemon, network = owner.network_id) {
47
+ const token = generateNetworkToken();
48
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,bound_node_id)
49
+ VALUES(?1,?2,?3,'network',?4,?5,?6)`, [label, user, network, `node:${alias}`, hashToken(token), node]);
50
+ return token;
51
+ }
52
+ legacy = credential("owner678_legacy", owner.user.user_id);
53
+ mismatched = credential("owner678_mismatch", member.user.user_id);
54
+ bound = credential("owner678_bound", member.user.user_id, daemon);
55
+ wrongBound = credential("owner678_wrong_bound", member.user.user_id, "n_absent_bound");
56
+ ownerless = credential("owner678_ownerless", member.user.user_id, null, "n_ownerless_fixture");
57
+ db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'");
58
+ revoked = credential("owner678_revoked", owner.user.user_id);
59
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id='owner678_revoked'");
60
+ crossNetwork = credential("owner678_cross", outsider.user.user_id, daemon, daemon, outsider.network_id);
61
+ const { bootServer } = await import("./server.js");
62
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${hub.port}`;
63
+ const request = await call(owner.token, "request_adopt_node", { node_id: child, daemon_node_id: daemon, workdir: "/fixture/project" });
64
+ expect(request.ok).toBe(true); requestId = request.request_id;
65
+ }, 60000);
66
+ afterAll(() => hub?.stop(true));
67
+
68
+ test("name-only issuance checks existing ownership atomically", async () => {
69
+ const count = () => db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
70
+ const before = count();
71
+ expect(await mint(member.token, owner.network_id, daemon)).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
72
+ expect(await mint(owner.token, owner.network_id, "n_ownerless_fixture")).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
73
+ expect(count()).toBe(before);
74
+ // Legacy owner refresh and a new node name remain supported.
75
+ const refresh = await mint(owner.token, owner.network_id, daemon);
76
+ expect(refresh.status).toBe(200);
77
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
78
+ expect((await mint(member.token, owner.network_id, "fresh678")).status).toBe(200);
79
+ expect((await mint(member.token, owner.network_id, daemon, daemon)).body.error).toBe("node_owner_mismatch");
80
+ });
81
+
82
+ // Every daemon-facing handler must fail at the shared identity boundary,
83
+ // not incidentally at request lookup or argument validation. The resolver
84
+ // mutation expects all 15 of these cases to go red together.
85
+ const endpoints: [string, Record<string, unknown>][] = [
86
+ ["list_my_pending_create_requests", {}], ["list_my_pending_lifecycle_requests", {}],
87
+ ["get_create_request", { request_id: "cr_fixture_absent" }],
88
+ ["ack_create_request", { request_id: "cr_fixture_absent", status: "started" }],
89
+ ["get_stop_request", { request_id: "sr_fixture_absent" }],
90
+ ["ack_stop_request", { request_id: "sr_fixture_absent", status: "stopped" }],
91
+ ["get_start_request", { request_id: "start_fixture_absent" }],
92
+ ["ack_start_request", { request_id: "start_fixture_absent", status: "started" }],
93
+ ["list_my_children", {}], ["get_adopt_request", {}], ["ack_adopt_request", { status: "adopted" }],
94
+ ["get_probe_request", { probe_id: "probe_fixture_absent" }],
95
+ ["ack_probe_request", { probe_id: "probe_fixture_absent", status: "ok", latency_ms: 1 }],
96
+ ["get_opencode_catalog_request", { request_id: "cat_fixture_absent" }],
97
+ ["ack_opencode_catalog", { request_id: "cat_fixture_absent", error: "unavailable" }],
98
+ ];
99
+ for (const [name, arguments_] of endpoints) {
100
+ test(`daemon identity boundary: ${name}`, async () => {
101
+ const args = name.includes("adopt_request") ? { ...arguments_, request_id: requestId } : arguments_;
102
+ for (const token of [mismatched, wrongBound, ownerless, owner.token, member.token]) {
103
+ expect(await call(token, name, args)).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
104
+ }
105
+ expect(db.get("SELECT status FROM node_daemon_bindings WHERE request_id=?1", requestId).status).toBe("pending");
106
+ });
107
+ }
108
+
109
+ test("legacy owner and exact bound identity remain supported", async () => {
110
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id='owner678_legacy'").bound_node_id).toBeNull();
111
+ for (const token of [legacy, bound]) {
112
+ expect((await call(token, "list_my_children")).ok).toBe(true);
113
+ expect((await call(token, "list_my_pending_create_requests")).ok).toBe(true);
114
+ expect((await call(token, "list_my_pending_lifecycle_requests")).ok).toBe(true);
115
+ expect((await call(token, "get_adopt_request", { request_id: requestId })).ok).toBe(true);
116
+ }
117
+ // An explicit binding does not bypass the network or revocation boundaries.
118
+ expect(await call(crossNetwork, "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
119
+ const denied = await fetch(`${base}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${revoked}` } });
120
+ expect(denied.status).toBe(401);
121
+ expect(await call(legacy, "ack_adopt_request", { request_id: requestId, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
122
+ expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted", binding_request_id: requestId });
123
+ const stop = await call(owner.token, "stop_node", { node_id: child, network_id: owner.network_id });
124
+ expect(stop.ok).toBe(true);
125
+ expect((await call(legacy, "get_stop_request", { request_id: stop.request_id })).ok).toBe(true);
126
+ expect((await call(legacy, "ack_stop_request", { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
127
+ const start = await call(owner.token, "start_node", { node_id: child, network_id: owner.network_id });
128
+ expect(start.ok).toBe(true);
129
+ expect((await call(legacy, "get_start_request", { request_id: start.request_id })).ok).toBe(true);
130
+ expect(await call(legacy, "ack_start_request", { request_id: start.request_id, status: "started", child_pid: 4321 })).toMatchObject({ ok: true, status: "started" });
131
+ });
132
+
133
+ test("ownerless legacy daemon remains supported without rewriting its owner", async () => {
134
+ db.run("UPDATE api_tokens SET node_identity_epoch=0 WHERE token_id='owner678_ownerless'");
135
+ try {
136
+ expect((await call(ownerless, "list_my_children")).ok).toBe(true);
137
+ const refresh = await mint(member.token, owner.network_id, "n_ownerless_fixture");
138
+ expect(refresh.status).toBe(200);
139
+ expect(refresh.body.node_id).toBe("n_ownerless_fixture");
140
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
141
+ const withId = await mint(member.token, owner.network_id, "n_ownerless_fixture", "n_ownerless_fixture");
142
+ expect(withId.status).toBe(200);
143
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_ownerless_fixture'").owner_user_id).toBeNull();
144
+ expect((await mint(owner.token, owner.network_id, "n_ownerless_fixture")).status).toBe(400);
145
+ } finally { db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'"); }
146
+ });
147
+
148
+ test("new name-only registration binds once and its holder can refresh with either shape", async () => {
149
+ const first = await mint(member.token, owner.network_id, "refresh_fixture");
150
+ expect(first.status).toBe(200);
151
+ expect(db.get("SELECT node_identity_epoch FROM api_tokens WHERE token_id=?1", first.body.token_id).node_identity_epoch).toBe(2);
152
+ const result = await call(first.body.token, "report_status", {
153
+ resume_id: "refresh-fixture-session", alias: "refresh_fixture", status: "idle", node_id: "n_refresh_fixture",
154
+ });
155
+ expect(result.ok).toBe(true);
156
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", first.body.token_id).bound_node_id).toBe("n_refresh_fixture");
157
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_refresh_fixture'").owner_user_id).toBeNull();
158
+ for (const id of [undefined, "n_refresh_fixture"]) {
159
+ const refresh = await mint(member.token, owner.network_id, "refresh_fixture", id);
160
+ expect(refresh.status).toBe(200);
161
+ expect(refresh.body.node_id).toBe("n_refresh_fixture");
162
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
163
+ }
164
+ expect((await mint(owner.token, owner.network_id, "refresh_fixture")).status).toBe(400);
165
+ });
166
+
167
+ test("fresh ID cannot mint another node alias", async () => {
168
+ const before = db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
169
+ expect((await mint(member.token, owner.network_id, daemon, "n_fresh_impostor")).status).toBe(400);
170
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id='n_fresh_impostor'")).toBeNull();
171
+ expect(db.get("SELECT COUNT(*) AS n FROM api_tokens").n).toBe(before);
172
+ expect((await call(legacy, "list_my_children")).ok).toBe(true);
173
+ });
174
+
175
+ test("duplicate legacy aliases use bound IDs and an unbound token follows its owner", async () => {
176
+ // Historical duplicate rows: the member's row was inserted first. Neither
177
+ // lookup order nor alias can redirect an exact-bound token. An unbound
178
+ // token resolves to the row its issuer owns.
179
+ const { hashToken, generateNetworkToken } = await import("./db.js");
180
+ const tokens: string[] = [];
181
+ for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
182
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id) VALUES(?1,'duplicate_fixture','duplicate_fixture',?2,?3)", [id, owner.network_id, user]);
183
+ const token = generateNetworkToken(); tokens.push(token);
184
+ db.run("INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,name,scope,bound_node_id) VALUES(?1,?2,?3,?4,'node:duplicate_fixture','network',?1)", [id, hashToken(token), user, owner.network_id]);
185
+ db.run("INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES(?1,?2,?1,?1,'/fixture',?3,'pending',1,1)", [id, owner.network_id, user]);
186
+ }
187
+ for (let i = 0; i < tokens.length; i++) {
188
+ const result = await call(tokens[i], "list_my_children");
189
+ expect(result.ok).toBe(true);
190
+ const ownId = i === 0 ? "n_duplicate_first" : "n_duplicate_owner";
191
+ expect((await call(tokens[i], "get_adopt_request", { request_id: ownId })).ok).toBe(true);
192
+ expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
193
+ }
194
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
195
+ expect((await call(tokens[1], "list_my_children")).ok).toBe(true);
196
+ expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(true);
197
+ expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(false);
198
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
199
+ expect((await call(tokens[0], "list_my_children")).ok).toBe(true);
200
+ expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(true);
201
+ expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(false);
202
+ expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
203
+ });
204
+
205
+ test("all production token issuers explicitly mark the rollout epoch", () => {
206
+ const root = `${import.meta.dir}/../..`;
207
+ function sources(dir: string): string[] {
208
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
209
+ const path = `${dir}/${entry.name}`;
210
+ return entry.isDirectory() ? sources(path) : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [path] : [];
211
+ });
212
+ }
213
+ const sourceFiles = ["server/src", "agent-network/src", "agent-network/bin", "agent-node/src"].flatMap(dir => sources(`${root}/${dir}`));
214
+ let count = 0;
215
+ for (const file of sourceFiles) {
216
+ const source = readFileSync(file, "utf8");
217
+ for (const match of source.matchAll(/INSERT\s+INTO\s+api_tokens\s*\(([^)]+)\)/gi)) {
218
+ expect(match[1]).toContain("node_identity_epoch"); count++;
219
+ }
220
+ }
221
+ // Hub #938: clone_node mints a child ntok_ with node_identity_epoch=2,
222
+ // the same column create_node already writes. 9 issuers → 10.
223
+ expect(count).toBe(10);
224
+ expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
225
+ });
226
+
227
+ test("revoked ownerless credential cannot be reclaimed by doctor and gives actionable guidance", async () => {
228
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE name='node:n_ownerless_fixture'");
229
+ const result = await mint(member.token, owner.network_id, "n_ownerless_fixture");
230
+ expect(result.status).toBe(400);
231
+ expect(result.body.error).toBe("node_owner_mismatch");
232
+ expect(nodeTokenReissueError(result.body)).toContain("这个节点没有归属,请联系管理员认领(#682)");
233
+ expect(nodeTokenReissueError({ error: "node_owner_unclaimed" })).toContain("请联系管理员认领(#682)");
234
+ expect(nodeTokenReissueError({ error: "network_not_found" })).toBe("network_not_found");
235
+ expect(readFileSync(`${import.meta.dir}/../../agent-network/bin/cli.ts`, "utf8")).toContain("body ? nodeTokenReissueError(body) : r.status");
236
+ });