@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,493 @@
1
+ // Webhooks (P4-T10; design §6.1, §12.2, §12.4; decision znbg8xa5 #8): the hub POSTs events to URLs that users and
2
+ // app tokens register.
3
+ //
4
+ // - Registrations per user in the StateStore collection "webhooks" (key: the id `wh_<16 hex>`): url, events
5
+ // (exact types or `launch.*`), an optional label, `by` (`user`, or `app:<token id>` for an app token's own) and
6
+ // the signing secret `whsec_…`, returned only by register. Users see and manage all of theirs; an app token only
7
+ // its own. An app registration gets only events of what that app started (`by` of the event), and is dropped
8
+ // once its token is revoked or loses the scope (appAlive).
9
+ // - URLs: https on a public host (publicURLProblem), checked at registration and again, re-resolved, right before
10
+ // every send (publicFetch: no redirects, timeout, bounded response read). hub.json agents.webhooks.allowPrivate
11
+ // admits http and private targets (self-hosted receivers).
12
+ // - Durable outbox in the StateStore collection "webhook-outbox", key `<webhook id>.<event id>`: at least once,
13
+ // deduplicated by event id (the first write wins, ifVersion 0), retried with backoff (backoffMs; then failed),
14
+ // one sender per delivery under a lease (Locks), so several hubs can drain the same store. Finished
15
+ // deliveries stay keepMs (GET …/:id shows them), then are pruned.
16
+ // - Each POST: the event JSON, Hub-Signature `t=<unix s>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, Hub-Event (its
17
+ // type) and Hub-Delivery (stable across retries), per packages/protocol/src/webhook.ts.
18
+ import { createHash, createHmac, randomBytes } from "node:crypto"
19
+ import {
20
+ formatWebhookSignature,
21
+ WEBHOOK_DELIVERY_HEADER,
22
+ WEBHOOK_EVENT_HEADER,
23
+ WEBHOOK_SIGNATURE_ALGORITHM,
24
+ WEBHOOK_SIGNATURE_HEADER,
25
+ webhookSignedPayload,
26
+ } from "../../protocol"
27
+ import type { Clock, Locks, StateStore, Timers } from "../backend/types"
28
+ import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
29
+ import { AgentError } from "./errors"
30
+ import type { AgentEvent, EventAudience, EventFamily } from "./events"
31
+ import { LAUNCH_STATES, type Launch } from "./launch-store"
32
+
33
+ export const WEBHOOKS = "webhooks"
34
+ export const WEBHOOK_OUTBOX = "webhook-outbox"
35
+ /** Every event type a webhook can receive (phase 4: launch changes). */
36
+ export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
37
+ "session.added", "session.removed", "session.changed", "session.archived", "session.named",
38
+ "session.pinned",
39
+ "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
+ "message.changed", "launch.changed",
41
+ ]
42
+ /** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
43
+ export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
44
+ export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
45
+ export const MAX_WEBHOOKS = 20
46
+ export const MAX_WEBHOOK_URL = 2048
47
+ export const MAX_WEBHOOK_LABEL = 100
48
+ export const MAX_WEBHOOK_EVENTS = 20
49
+ /** Waits between attempts: 7 attempts over about 8.5 hours. */
50
+ export const WEBHOOK_BACKOFF_MS: readonly number[] = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000]
51
+ export const WEBHOOK_TIMEOUT_MS = 10_000
52
+ /** Bytes of a receiver's response the hub reads. */
53
+ export const WEBHOOK_RESPONSE_MAX = 64 * 1024
54
+ export const WEBHOOK_KEEP_MS = 86_400_000
55
+ /** Deliveries per user in the outbox; past it new events are dropped (logged). */
56
+ export const WEBHOOK_OUTBOX_MAX = 5000
57
+ const POLL_MS = 5000
58
+ const CONCURRENCY = 8
59
+
60
+ /** An event as webhooks carry it (CloudEvents-shaped, design §12.2). */
61
+ export interface WebhookEvent {
62
+ readonly id: string
63
+ readonly type: string
64
+ /** The hub's public URL. */
65
+ readonly source: string
66
+ readonly subject: string
67
+ /** ISO 8601. */
68
+ readonly time: string
69
+ readonly user: string
70
+ readonly schemaVersion: 1
71
+ readonly data: unknown
72
+ }
73
+
74
+ /** Who a registration belongs to: the user, or one of their app tokens. */
75
+ export type WebhookOwner = "user" | `app:${string}`
76
+
77
+ interface Registration {
78
+ readonly id: string
79
+ readonly url: string
80
+ readonly events: readonly string[]
81
+ readonly by: WebhookOwner
82
+ readonly label?: string
83
+ readonly created: number
84
+ readonly secret: string
85
+ }
86
+
87
+ /** A registration as the API shows it (never its secret). */
88
+ export type WebhookView = Omit<Registration, "secret">
89
+
90
+ export type DeliveryState = "pending" | "delivered" | "failed"
91
+
92
+ export interface DeliveryAttempt {
93
+ readonly at: number
94
+ readonly status?: number
95
+ readonly error?: string
96
+ }
97
+
98
+ interface OutboxEntry {
99
+ /** The Hub-Delivery header. */
100
+ readonly id: string
101
+ readonly webhook: string
102
+ readonly event: WebhookEvent
103
+ readonly state: DeliveryState
104
+ readonly attempts: number
105
+ readonly created: number
106
+ readonly nextAt: number
107
+ readonly finishedAt?: number
108
+ readonly last?: DeliveryAttempt
109
+ }
110
+
111
+ export interface DeliveryView {
112
+ readonly id: string
113
+ readonly event: string
114
+ readonly type: string
115
+ readonly state: DeliveryState
116
+ readonly attempts: number
117
+ readonly created: number
118
+ readonly nextAt?: number
119
+ readonly last?: DeliveryAttempt
120
+ }
121
+
122
+ export interface RegisterInput {
123
+ readonly url?: unknown
124
+ readonly events?: unknown
125
+ readonly label?: unknown
126
+ }
127
+
128
+ export interface WebhooksDeps {
129
+ readonly state: StateStore
130
+ /** One sender per delivery across hubs; without it, this process is the only sender. */
131
+ readonly locks?: Locks
132
+ readonly now?: Clock
133
+ readonly timers?: Timers
134
+ /** The events' source (the hub's public URL). */
135
+ readonly source: string
136
+ /** Every user ID (the sender drains each user's outbox). */
137
+ readonly users: () => readonly string[]
138
+ readonly resolve: ResolveHost
139
+ /** hub.json agents.webhooks.allowPrivate. */
140
+ readonly allowPrivate?: boolean
141
+ readonly fetch?: typeof globalThis.fetch
142
+ /** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
143
+ readonly appAlive?: (userID: string, tokenID: string) => boolean
144
+ /** Current scopes of an app token; absent preserves the legacy liveness-only check. */
145
+ readonly appScopes?: (userID: string, tokenID: string) => readonly string[] | undefined
146
+ readonly backoffMs?: readonly number[]
147
+ readonly timeoutMs?: number
148
+ readonly maxResponseBytes?: number
149
+ readonly keepMs?: number
150
+ readonly maxWebhooks?: number
151
+ readonly maxOutbox?: number
152
+ readonly pollMs?: number
153
+ /** The lease owner (unique per hub process). */
154
+ readonly owner?: string
155
+ readonly userAgent?: string
156
+ }
157
+
158
+ const defaultTimers: Timers = {
159
+ set: (fn, ms) => {
160
+ const handle = setTimeout(fn, ms)
161
+ ;(handle as { unref?: () => void }).unref?.()
162
+ return handle
163
+ },
164
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
165
+ }
166
+
167
+ const hex = (text: string, length: number) => createHash("sha256").update(text).digest("hex").slice(0, length)
168
+ const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
169
+
170
+ /** Whether a registration's pattern (an exact type or `<prefix>.*`) covers an event type. */
171
+ function matches(pattern: string, type: string): boolean {
172
+ return pattern.endsWith(".*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
173
+ }
174
+
175
+ function eventsOf(raw: unknown): string[] {
176
+ if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
177
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_WEBHOOK_EVENTS || !raw.every((e): e is string => typeof e === "string")) {
178
+ throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*", "message.changed"]`)
179
+ }
180
+ for (const pattern of raw) {
181
+ if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, "Use a canonical event type or family wildcard")
182
+ }
183
+ return [...new Set(raw)]
184
+ }
185
+
186
+ export function webhookEventScopes(patterns: readonly string[]): string[] {
187
+ const scopes = new Set<string>()
188
+ if (patterns.some((pattern) => LAUNCH_STATES.some((state) => matches(pattern, `launch.${state}`)))) scopes.add("sessions:launch")
189
+ for (const pattern of patterns) for (const type of CANONICAL_WEBHOOK_EVENT_TYPES) if (matches(pattern, type)) {
190
+ if (type === "message.changed") scopes.add("messages:read")
191
+ else if (type === "launch.changed") scopes.add("sessions:launch")
192
+ else if (["session.archived", "session.named", "session.pinned", "role.claimed", "role.released"].includes(type)) scopes.add("sessions:manage")
193
+ else scopes.add("sessions:read")
194
+ }
195
+ return [...scopes]
196
+ }
197
+
198
+ export function canonicalWebhookEvent(source: string, userID: string, event: AgentEvent): WebhookEvent {
199
+ return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
200
+ }
201
+
202
+ const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
203
+
204
+ const deliveryView = (entry: OutboxEntry): DeliveryView => ({
205
+ id: entry.id,
206
+ event: entry.event.id,
207
+ type: entry.event.type,
208
+ state: entry.state,
209
+ attempts: entry.attempts,
210
+ created: entry.created,
211
+ ...(entry.state === "pending" ? { nextAt: entry.nextAt } : {}),
212
+ ...(entry.last ? { last: entry.last } : {}),
213
+ })
214
+
215
+ /** The webhook event for a launch change: `launch.<state>`, its id stable per step (launch, state, time). */
216
+ export function launchEvent(source: string, launch: Launch, data: unknown): WebhookEvent {
217
+ return {
218
+ id: `evt_${hex(`${launch.id}\u0000${launch.state}\u0000${launch.at}`, 24)}`,
219
+ type: `launch.${launch.state}`,
220
+ source,
221
+ subject: launch.id,
222
+ time: new Date(launch.at).toISOString(),
223
+ user: launch.owner,
224
+ schemaVersion: 1,
225
+ data: { launch: data },
226
+ }
227
+ }
228
+
229
+ /** The hex HMAC-SHA256 over `<t>.<body>` (Hub-Signature v1). */
230
+ export function signWebhook(secret: string, t: number, body: string): string {
231
+ return createHmac(WEBHOOK_SIGNATURE_ALGORITHM, secret).update(webhookSignedPayload(t, body)).digest("hex")
232
+ }
233
+
234
+ export class Webhooks {
235
+ private readonly now: Clock
236
+ private readonly timers: Timers
237
+ private readonly backoffMs: readonly number[]
238
+ private readonly owner: string
239
+ private running: Promise<void> | undefined
240
+ private again = false
241
+ private started = false
242
+ private poll: unknown
243
+ private kickTimer: unknown
244
+
245
+ constructor(private readonly deps: WebhooksDeps) {
246
+ this.now = deps.now ?? Date.now
247
+ this.timers = deps.timers ?? defaultTimers
248
+ this.backoffMs = deps.backoffMs ?? WEBHOOK_BACKOFF_MS
249
+ this.owner = deps.owner ?? `hub-${process.pid}-${randomBytes(4).toString("hex")}`
250
+ }
251
+
252
+ async register(userID: string, by: WebhookOwner, input: RegisterInput): Promise<{ webhook: WebhookView; secret: string }> {
253
+ const { url } = input
254
+ if (typeof url !== "string" || url.length === 0) throw invalid("url must be a string")
255
+ if (url.length > MAX_WEBHOOK_URL) throw invalid(`url must be at most ${MAX_WEBHOOK_URL} characters`)
256
+ const problem = await publicURLProblem(url, { resolve: this.deps.resolve, label: "Webhook URL", allowPrivate: this.deps.allowPrivate === true })
257
+ if (problem) throw invalid(problem, this.deps.allowPrivate ? undefined : "Webhooks need an https URL on a public host")
258
+ const events = eventsOf(input.events)
259
+ let label: string | undefined
260
+ if (input.label !== undefined && input.label !== null) {
261
+ if (typeof input.label !== "string") throw invalid("label must be a string")
262
+ label = input.label.trim() || undefined
263
+ if (label && label.length > MAX_WEBHOOK_LABEL) throw invalid(`label must be at most ${MAX_WEBHOOK_LABEL} characters`)
264
+ }
265
+ const max = this.deps.maxWebhooks ?? MAX_WEBHOOKS
266
+ if ((await this.deps.state.list(userID, WEBHOOKS)).length >= max) throw new AgentError("rate_limited", `At most ${max} webhooks per user`, { hint: "Delete unused webhooks" })
267
+ const registration: Registration = {
268
+ id: `wh_${randomBytes(8).toString("hex")}`,
269
+ url,
270
+ events,
271
+ by,
272
+ ...(label ? { label } : {}),
273
+ created: this.now(),
274
+ secret: `whsec_${randomBytes(32).toString("base64url")}`,
275
+ }
276
+ const written = await this.deps.state.put(userID, WEBHOOKS, registration.id, registration, { ifVersion: 0 })
277
+ if (!written.ok) throw new Error("webhook id collision")
278
+ return { webhook: view(registration), secret: registration.secret }
279
+ }
280
+
281
+ /** The user's registrations (by: only that owner's), by id. */
282
+ async list(userID: string, by?: WebhookOwner): Promise<WebhookView[]> {
283
+ return (await this.registrations(userID)).filter((r) => by === undefined || r.by === by).map(view)
284
+ }
285
+
286
+ async get(userID: string, id: string, by?: WebhookOwner): Promise<WebhookView | undefined> {
287
+ const registration = await this.registration(userID, id, by)
288
+ return registration && view(registration)
289
+ }
290
+
291
+ /** Deletes a registration and its outbox; false when it is not the caller's. */
292
+ async remove(userID: string, id: string, by?: WebhookOwner): Promise<boolean> {
293
+ if (!(await this.registration(userID, id, by))) return false
294
+ await this.deps.state.delete(userID, WEBHOOKS, id)
295
+ for (const entry of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
296
+ if (entry.value.webhook === id) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, entry.key)
297
+ }
298
+ return true
299
+ }
300
+
301
+ /** A registration's deliveries still kept, oldest first. */
302
+ async deliveries(userID: string, id: string): Promise<DeliveryView[]> {
303
+ return (await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX))
304
+ .map((e) => e.value)
305
+ .filter((e) => e.webhook === id)
306
+ .sort((a, b) => a.created - b.created || a.id.localeCompare(b.id))
307
+ .map(deliveryView)
308
+ }
309
+
310
+ /**
311
+ * Queues an event for every matching registration of the user: user registrations always, app registrations
312
+ * only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
313
+ * already queued for a registration is not queued again).
314
+ */
315
+ async publish(userID: string, event: WebhookEvent, options: { readonly by?: string; readonly owners?: readonly string[]; readonly matchTypes?: readonly string[] } = {}): Promise<number> {
316
+ const matchTypes = options.matchTypes ?? [event.type]
317
+ const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by || options.owners?.includes(r.by)) && r.events.some((p) => matchTypes.some((type) => matches(p, type))))
318
+ if (!targets.length) return 0
319
+ const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
320
+ let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
321
+ let queued = 0
322
+ for (const registration of targets) {
323
+ if (size >= max) {
324
+ console.error(`[hub] webhook outbox of ${userID} is full; event ${event.id} dropped for ${registration.id}`)
325
+ break
326
+ }
327
+ const key = `${registration.id}.${event.id}`
328
+ const entry: OutboxEntry = { id: `whd_${hex(key, 24)}`, webhook: registration.id, event, state: "pending", attempts: 0, created: this.now(), nextAt: this.now() }
329
+ if ((await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, entry, { ifVersion: 0 })).ok) {
330
+ queued++
331
+ size++
332
+ }
333
+ }
334
+ if (queued) this.kick()
335
+ return queued
336
+ }
337
+
338
+ publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
339
+ const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
340
+ return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
341
+ ...(audience?.by ? { by: audience.by } : {}),
342
+ ...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
343
+ ...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
344
+ })
345
+ }
346
+
347
+ /** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
348
+ async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
349
+ return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
350
+ }
351
+
352
+ /** Starts the sender: drains now and every pollMs, and soon after each publish. */
353
+ start(): void {
354
+ if (this.started) return
355
+ this.started = true
356
+ const tick = () => {
357
+ this.poll = this.timers.set(() => {
358
+ void this.drain()
359
+ .catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
360
+ .finally(() => this.started && tick())
361
+ }, this.deps.pollMs ?? POLL_MS)
362
+ }
363
+ this.kick()
364
+ tick()
365
+ }
366
+
367
+ stop(): void {
368
+ this.started = false
369
+ if (this.poll !== undefined) this.timers.clear(this.poll)
370
+ if (this.kickTimer !== undefined) this.timers.clear(this.kickTimer)
371
+ this.poll = this.kickTimer = undefined
372
+ }
373
+
374
+ /** Sends every due delivery once (and prunes finished ones); concurrent calls share one run. */
375
+ drain(): Promise<void> {
376
+ if (this.running) {
377
+ this.again = true
378
+ return this.running
379
+ }
380
+ this.running = (async () => {
381
+ do {
382
+ this.again = false
383
+ for (const userID of this.deps.users()) await this.drainUser(userID)
384
+ } while (this.again)
385
+ })().finally(() => {
386
+ this.running = undefined
387
+ })
388
+ return this.running
389
+ }
390
+
391
+ private kick(): void {
392
+ if (!this.started || this.kickTimer !== undefined) return
393
+ this.kickTimer = this.timers.set(() => {
394
+ this.kickTimer = undefined
395
+ void this.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
396
+ }, 0)
397
+ }
398
+
399
+ private async registrations(userID: string): Promise<Registration[]> {
400
+ return (await this.deps.state.list<Registration>(userID, WEBHOOKS)).map((e) => e.value).sort((a, b) => a.id.localeCompare(b.id))
401
+ }
402
+
403
+ private async registration(userID: string, id: string, by?: WebhookOwner): Promise<Registration | undefined> {
404
+ const found = (await this.deps.state.get<Registration>(userID, WEBHOOKS, id))?.value
405
+ return found && (by === undefined || found.by === by) ? found : undefined
406
+ }
407
+
408
+ private async drainUser(userID: string): Promise<void> {
409
+ const now = this.now()
410
+ const keep = this.deps.keepMs ?? WEBHOOK_KEEP_MS
411
+ const due: string[] = []
412
+ for (const { key, value, version } of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
413
+ if (value.state !== "pending") {
414
+ if (now - (value.finishedAt ?? value.created) > keep) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key, { ifVersion: version })
415
+ } else if (value.nextAt <= now) due.push(key)
416
+ }
417
+ for (let i = 0; i < due.length; i += CONCURRENCY) await Promise.all(due.slice(i, i + CONCURRENCY).map((key) => this.attempt(userID, key)))
418
+ }
419
+
420
+ /** One attempt at one delivery, under its lease. */
421
+ private async attempt(userID: string, key: string): Promise<void> {
422
+ const timeoutMs = this.deps.timeoutMs ?? WEBHOOK_TIMEOUT_MS
423
+ const lockKey = `webhook:${userID}:${key}`
424
+ const { locks } = this.deps
425
+ if (locks && !(await locks.acquire(lockKey, this.owner, timeoutMs + 30_000))) return
426
+ try {
427
+ const current = await this.deps.state.get<OutboxEntry>(userID, WEBHOOK_OUTBOX, key)
428
+ const now = this.now()
429
+ if (!current || current.value.state !== "pending" || current.value.nextAt > now) return
430
+ const entry = current.value
431
+ const registration = await this.registration(userID, entry.webhook)
432
+ if (!registration) {
433
+ await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key)
434
+ return
435
+ }
436
+ if (registration.by !== "user" && this.deps.appAlive && !this.deps.appAlive(userID, registration.by.slice("app:".length))) {
437
+ await this.remove(userID, registration.id)
438
+ return
439
+ }
440
+ if (registration.by !== "user" && this.deps.appScopes) {
441
+ const scopes = this.deps.appScopes(userID, registration.by.slice("app:".length))
442
+ const required = ["events:subscribe", ...webhookEventScopes(registration.events)]
443
+ if (!scopes || required.some((scope) => !scopes.includes(scope))) {
444
+ await this.remove(userID, registration.id)
445
+ return
446
+ }
447
+ }
448
+ const { last, final } = await this.send(registration, entry, now, timeoutMs)
449
+ const attempts = entry.attempts + 1
450
+ const delivered = last.status !== undefined && last.status >= 200 && last.status < 300
451
+ const done = delivered || final || attempts > this.backoffMs.length
452
+ const next: OutboxEntry = done
453
+ ? { ...entry, attempts, last, state: delivered ? "delivered" : "failed", finishedAt: this.now() }
454
+ : { ...entry, attempts, last, nextAt: now + this.backoffMs[attempts - 1]! }
455
+ await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, next, { ifVersion: current.version })
456
+ } finally {
457
+ if (locks) await locks.release(lockKey, this.owner)
458
+ }
459
+ }
460
+
461
+ private async send(registration: Registration, entry: OutboxEntry, now: number, timeoutMs: number): Promise<{ last: DeliveryAttempt; final: boolean }> {
462
+ const body = JSON.stringify(entry.event)
463
+ const t = Math.floor(now / 1000)
464
+ try {
465
+ const result = await publicFetch(
466
+ registration.url,
467
+ {
468
+ method: "POST",
469
+ headers: {
470
+ "content-type": "application/json",
471
+ "user-agent": this.deps.userAgent ?? "hub-webhooks",
472
+ [WEBHOOK_SIGNATURE_HEADER]: formatWebhookSignature(t, [signWebhook(registration.secret, t, body)]),
473
+ [WEBHOOK_EVENT_HEADER]: entry.event.type,
474
+ [WEBHOOK_DELIVERY_HEADER]: entry.id,
475
+ },
476
+ body,
477
+ },
478
+ {
479
+ resolve: this.deps.resolve,
480
+ label: "Webhook URL",
481
+ allowPrivate: this.deps.allowPrivate === true,
482
+ timeoutMs,
483
+ maxBytes: this.deps.maxResponseBytes ?? WEBHOOK_RESPONSE_MAX,
484
+ ...(this.deps.fetch ? { fetch: this.deps.fetch } : {}),
485
+ },
486
+ )
487
+ return { last: { at: now, status: result.status }, final: false }
488
+ } catch (cause) {
489
+ const error = (cause instanceof Error ? cause.message : String(cause)).slice(0, 300)
490
+ return { last: { at: now, error }, final: cause instanceof PublicURLError }
491
+ }
492
+ }
493
+ }
@@ -0,0 +1,177 @@
1
+ // Per-user agent control plane data stored on UserRecord.agent in state.json: session names, roles, session
2
+ // records and app/agent tokens (hashes only). Storage only: validation, normalisation and merging. Unknown
3
+ // fields (on the object and on each entry) are kept, so data written by a newer hub survives this one.
4
+ // It lives inside `users`, which older hubs persist as-is, so a downgrade keeps it.
5
+ // Lifecycle rules (release, alias expiry, offline role loss) are evaluated lazily by agent/directory.ts.
6
+ import { withSuffix } from "./agent/names"
7
+
8
+ /** `@name` → session. Unique per user by name (current names and aliases share one namespace). */
9
+ export interface AgentName {
10
+ name: string
11
+ sessionID: string
12
+ /** When it was set (ms). */
13
+ created?: number
14
+ /** Set on an alias (the name before a rename): it is released at this time (ms). */
15
+ expiresAt?: number
16
+ [field: string]: unknown
17
+ }
18
+
19
+ /** `@@role` holder. Unique per user by (role, project, sessionID). */
20
+ export interface AgentRole {
21
+ role: string
22
+ sessionID: string
23
+ /** Project scope (`role.project`); absent for an unscoped claim. */
24
+ project?: string
25
+ /** Holder generation: every new holder of (role, project) gets the next one. */
26
+ generation?: number
27
+ /** Claimed with --exclusive: nobody else may hold (role, project) while this holder is live. */
28
+ exclusive?: boolean
29
+ claimedAt?: number
30
+ [field: string]: unknown
31
+ }
32
+
33
+ /** A session the control plane knows about. Unique per user by sessionID. */
34
+ export interface AgentSession {
35
+ sessionID: string
36
+ machineID?: string
37
+ parentID?: string
38
+ /** Project name (lowercased) used by `.project` scopes. */
39
+ project?: string
40
+ title?: string
41
+ firstSeen?: number
42
+ /** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
43
+ lastSeen?: number
44
+ /** When the hub saw it archived (the console's Archive, viewPrefs.hiddenSessions). */
45
+ archivedAt?: number
46
+ deletedAt?: number
47
+ /** Name derivation already happened (derived once, from the first non-placeholder title). */
48
+ named?: boolean
49
+ /** When the one-time identity primer reached the session (ms); absent: not primed yet. */
50
+ primed?: number
51
+ [field: string]: unknown
52
+ }
53
+
54
+ /** A user-created app/agent token: only the sha256 hash is stored. Unique per user by id. */
55
+ export interface AppToken {
56
+ id: string
57
+ tokenHash: string
58
+ label?: string
59
+ scopes?: string[]
60
+ created?: number
61
+ [field: string]: unknown
62
+ }
63
+
64
+ export interface AgentData {
65
+ names?: AgentName[]
66
+ roles?: AgentRole[]
67
+ sessions?: AgentSession[]
68
+ appTokens?: AppToken[]
69
+ /** Last holder generation handed out per roleKey(role, project). */
70
+ generations?: Record<string, number>
71
+ /** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
72
+ actingPaused?: true
73
+ [field: string]: unknown
74
+ }
75
+
76
+ /**
77
+ * Key of a role and its project scope (for generations and exclusivity): `role` or `role.project` (a role never
78
+ * contains a dot). Never a NUL: a Postgres jsonb string cannot hold one (cluster mode).
79
+ */
80
+ export const roleKey = (role: string, project?: string): string => (project ? `${role}.${project}` : role)
81
+
82
+ /** Generation keys of older hubs: role NUL project. */
83
+ const LEGACY_ROLE_KEY = "\u0000"
84
+
85
+ type Entry = Record<string, unknown>
86
+
87
+ /** Each list: the string fields an entry needs, and its identity (entries with the same one are duplicates). */
88
+ const LISTS: Record<"names" | "roles" | "sessions" | "appTokens", { required: readonly string[]; identity: (e: Entry) => string }> = {
89
+ names: { required: ["name", "sessionID"], identity: (e) => e.name as string },
90
+ roles: { required: ["role", "sessionID"], identity: (e) => `${roleKey(e.role as string, typeof e.project === "string" ? e.project : undefined)}\u0000${e.sessionID}` },
91
+ sessions: { required: ["sessionID"], identity: (e) => e.sessionID as string },
92
+ appTokens: { required: ["id", "tokenHash"], identity: (e) => e.id as string },
93
+ }
94
+
95
+ const isPlain = (v: unknown): v is Entry => typeof v === "object" && v !== null && !Array.isArray(v)
96
+ const isList = (key: string): key is keyof typeof LISTS => Object.hasOwn(LISTS, key)
97
+
98
+ /** Valid entries of a list, first of each identity kept. */
99
+ function normaliseList(key: keyof typeof LISTS, value: readonly unknown[]): Entry[] {
100
+ const { required, identity } = LISTS[key]
101
+ const seen = new Set<string>()
102
+ const out: Entry[] = []
103
+ for (const entry of value) {
104
+ if (!isPlain(entry) || !required.every((field) => typeof entry[field] === "string" && entry[field] !== "")) continue
105
+ const id = identity(entry)
106
+ if (seen.has(id)) continue
107
+ seen.add(id)
108
+ out.push(entry)
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** Generation counters: non-negative safe integers only. */
114
+ function normaliseGenerations(value: Entry): Record<string, number> {
115
+ const out: Record<string, number> = {}
116
+ for (const [raw, n] of Object.entries(value)) {
117
+ if (!validEpoch(n)) continue
118
+ const legacy = raw.indexOf(LEGACY_ROLE_KEY)
119
+ const key = legacy < 0 ? raw : roleKey(raw.slice(0, legacy), raw.slice(legacy + 1))
120
+ out[key] = Math.max(out[key] ?? 0, n)
121
+ }
122
+ return out
123
+ }
124
+
125
+ /**
126
+ * Agent data from a stored or updated value: undefined when it is not an object; a known list that is not an
127
+ * array is dropped; invalid and duplicate entries are dropped. Idempotent; key order is kept.
128
+ */
129
+ export function normaliseAgentData(value: unknown): AgentData | undefined {
130
+ if (!isPlain(value)) return undefined
131
+ const out: Entry = {}
132
+ for (const [key, item] of Object.entries(value)) {
133
+ if (key === "generations") {
134
+ if (isPlain(item)) out[key] = normaliseGenerations(item)
135
+ } else if (!isList(key)) out[key] = item
136
+ else if (Array.isArray(item)) out[key] = normaliseList(key, item)
137
+ }
138
+ return out as AgentData
139
+ }
140
+
141
+ /** `from`'s names after `have`: a name `have` holds for another session gets a suffix (-2, -3, …). */
142
+ function mergeNames(have: Entry[], from: readonly unknown[]): Entry[] {
143
+ const out = normaliseList("names", have)
144
+ const owner = new Map(out.map((e) => [e.name as string, e.sessionID as string]))
145
+ for (const entry of normaliseList("names", from)) {
146
+ const held = owner.get(entry.name as string)
147
+ if (held === entry.sessionID) continue
148
+ const name = held === undefined ? (entry.name as string) : withSuffix(entry.name as string, (n) => owner.has(n))
149
+ owner.set(name, entry.sessionID as string)
150
+ out.push({ ...entry, name })
151
+ }
152
+ return out
153
+ }
154
+
155
+ /**
156
+ * `into` plus what `from` adds: list entries with a new identity, other fields `into` lacks. A name of
157
+ * `from` that `into` holds for a different session is kept with a suffix; generations take the higher counter.
158
+ */
159
+ export function mergeAgentData(into: AgentData, from: AgentData): AgentData {
160
+ const out: Entry = { ...into }
161
+ for (const [key, item] of Object.entries(from)) {
162
+ if (key === "names" && Array.isArray(item)) {
163
+ out[key] = mergeNames(Array.isArray(out[key]) ? (out[key] as Entry[]) : [], item)
164
+ } else if (key === "generations" && isPlain(item)) {
165
+ const merged = normaliseGenerations(isPlain(out[key]) ? (out[key] as Entry) : {})
166
+ for (const [k, n] of Object.entries(normaliseGenerations(item))) merged[k] = Math.max(merged[k] ?? 0, n)
167
+ out[key] = merged
168
+ } else if (isList(key) && Array.isArray(item)) {
169
+ const have = Array.isArray(out[key]) ? (out[key] as Entry[]) : []
170
+ out[key] = normaliseList(key, [...have, ...item])
171
+ } else if (!(key in out)) out[key] = item
172
+ }
173
+ return out as AgentData
174
+ }
175
+
176
+ /** A stored token epoch: a non-negative safe integer, else absent (read as 0). */
177
+ export const validEpoch = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0