aicodeman 1.44.0 → 1.46.0

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 (351) hide show
  1. package/README.md +32 -8
  2. package/dist/agent-codex-identity.d.ts +26 -0
  3. package/dist/agent-codex-identity.d.ts.map +1 -0
  4. package/dist/agent-codex-identity.js +253 -0
  5. package/dist/agent-codex-identity.js.map +1 -0
  6. package/dist/agent-inspection.d.ts +32 -0
  7. package/dist/agent-inspection.d.ts.map +1 -0
  8. package/dist/agent-inspection.js +196 -0
  9. package/dist/agent-inspection.js.map +1 -0
  10. package/dist/agent-staging.d.ts +130 -0
  11. package/dist/agent-staging.d.ts.map +1 -0
  12. package/dist/agent-staging.js +312 -0
  13. package/dist/agent-staging.js.map +1 -0
  14. package/dist/case-rename.d.ts +65 -0
  15. package/dist/case-rename.d.ts.map +1 -0
  16. package/dist/case-rename.js +198 -0
  17. package/dist/case-rename.js.map +1 -0
  18. package/dist/cli-agent.d.ts +91 -3
  19. package/dist/cli-agent.d.ts.map +1 -1
  20. package/dist/cli-agent.js +501 -44
  21. package/dist/cli-agent.js.map +1 -1
  22. package/dist/cli-history.d.ts +20 -0
  23. package/dist/cli-history.d.ts.map +1 -0
  24. package/dist/cli-history.js +61 -0
  25. package/dist/cli-history.js.map +1 -0
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +6 -1
  28. package/dist/cli.js.map +1 -1
  29. package/dist/codex-rollout-format.d.ts +66 -0
  30. package/dist/codex-rollout-format.d.ts.map +1 -0
  31. package/dist/codex-rollout-format.js +111 -0
  32. package/dist/codex-rollout-format.js.map +1 -0
  33. package/dist/codex-transcript.d.ts +3 -0
  34. package/dist/codex-transcript.d.ts.map +1 -1
  35. package/dist/codex-transcript.js +13 -41
  36. package/dist/codex-transcript.js.map +1 -1
  37. package/dist/config/auth-config.d.ts +16 -1
  38. package/dist/config/auth-config.d.ts.map +1 -1
  39. package/dist/config/auth-config.js +16 -1
  40. package/dist/config/auth-config.js.map +1 -1
  41. package/dist/config/cli-registry/schema.d.ts +12 -7
  42. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  43. package/dist/config/cli-registry/schema.js +26 -0
  44. package/dist/config/cli-registry/schema.js.map +1 -1
  45. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  46. package/dist/config/cli-registry/stock.js +40 -1
  47. package/dist/config/cli-registry/stock.js.map +1 -1
  48. package/dist/config/cli-registry/types.d.ts +57 -0
  49. package/dist/config/cli-registry/types.d.ts.map +1 -1
  50. package/dist/config/server-timing.d.ts +6 -0
  51. package/dist/config/server-timing.d.ts.map +1 -1
  52. package/dist/config/server-timing.js +9 -0
  53. package/dist/config/server-timing.js.map +1 -1
  54. package/dist/daemon-control.d.ts +1 -0
  55. package/dist/daemon-control.d.ts.map +1 -1
  56. package/dist/daemon-control.js +2 -0
  57. package/dist/daemon-control.js.map +1 -1
  58. package/dist/deepseek-transcript.d.ts +9 -7
  59. package/dist/deepseek-transcript.d.ts.map +1 -1
  60. package/dist/deepseek-transcript.js +9 -7
  61. package/dist/deepseek-transcript.js.map +1 -1
  62. package/dist/docker-hosts.d.ts +8 -0
  63. package/dist/docker-hosts.d.ts.map +1 -1
  64. package/dist/docker-hosts.js +29 -0
  65. package/dist/docker-hosts.js.map +1 -1
  66. package/dist/git-fetch-scheduler.d.ts +124 -0
  67. package/dist/git-fetch-scheduler.d.ts.map +1 -0
  68. package/dist/git-fetch-scheduler.js +249 -0
  69. package/dist/git-fetch-scheduler.js.map +1 -0
  70. package/dist/git-fetch.d.ts +241 -0
  71. package/dist/git-fetch.d.ts.map +1 -0
  72. package/dist/git-fetch.js +392 -0
  73. package/dist/git-fetch.js.map +1 -0
  74. package/dist/git-workspace-status.d.ts +36 -1
  75. package/dist/git-workspace-status.d.ts.map +1 -1
  76. package/dist/git-workspace-status.js +42 -5
  77. package/dist/git-workspace-status.js.map +1 -1
  78. package/dist/hooks-config.d.ts +14 -2
  79. package/dist/hooks-config.d.ts.map +1 -1
  80. package/dist/hooks-config.js +39 -3
  81. package/dist/hooks-config.js.map +1 -1
  82. package/dist/host-sensors.d.ts +115 -0
  83. package/dist/host-sensors.d.ts.map +1 -0
  84. package/dist/host-sensors.js +457 -0
  85. package/dist/host-sensors.js.map +1 -0
  86. package/dist/local-history.d.ts +71 -0
  87. package/dist/local-history.d.ts.map +1 -0
  88. package/dist/local-history.js +750 -0
  89. package/dist/local-history.js.map +1 -0
  90. package/dist/mcp-server.d.ts.map +1 -1
  91. package/dist/mcp-server.js +2 -2
  92. package/dist/mcp-server.js.map +1 -1
  93. package/dist/mux-interface.d.ts +5 -2
  94. package/dist/mux-interface.d.ts.map +1 -1
  95. package/dist/prompt-cache-keepwarm.d.ts.map +1 -1
  96. package/dist/prompt-cache-keepwarm.js +5 -3
  97. package/dist/prompt-cache-keepwarm.js.map +1 -1
  98. package/dist/remote-hosts.d.ts +7 -0
  99. package/dist/remote-hosts.d.ts.map +1 -1
  100. package/dist/remote-hosts.js +17 -3
  101. package/dist/remote-hosts.js.map +1 -1
  102. package/dist/remote-pane-history.d.ts.map +1 -1
  103. package/dist/remote-pane-history.js +2 -2
  104. package/dist/remote-pane-history.js.map +1 -1
  105. package/dist/session.d.ts +113 -0
  106. package/dist/session.d.ts.map +1 -1
  107. package/dist/session.js +354 -13
  108. package/dist/session.js.map +1 -1
  109. package/dist/terminal-input-classifier.d.ts +8 -0
  110. package/dist/terminal-input-classifier.d.ts.map +1 -0
  111. package/dist/terminal-input-classifier.js +60 -0
  112. package/dist/terminal-input-classifier.js.map +1 -0
  113. package/dist/tmux-manager.d.ts +30 -4
  114. package/dist/tmux-manager.d.ts.map +1 -1
  115. package/dist/tmux-manager.js +101 -21
  116. package/dist/tmux-manager.js.map +1 -1
  117. package/dist/tmux-prefix-table.d.ts +37 -5
  118. package/dist/tmux-prefix-table.d.ts.map +1 -1
  119. package/dist/tmux-prefix-table.js +40 -5
  120. package/dist/tmux-prefix-table.js.map +1 -1
  121. package/dist/tui/tui-app.d.ts +8 -1
  122. package/dist/tui/tui-app.d.ts.map +1 -1
  123. package/dist/tui/tui-app.js +49 -12
  124. package/dist/tui/tui-app.js.map +1 -1
  125. package/dist/tui/tui-client.d.ts +90 -3
  126. package/dist/tui/tui-client.d.ts.map +1 -1
  127. package/dist/tui/tui-client.js +195 -26
  128. package/dist/tui/tui-client.js.map +1 -1
  129. package/dist/types/session.d.ts +15 -0
  130. package/dist/types/session.d.ts.map +1 -1
  131. package/dist/types/session.js.map +1 -1
  132. package/dist/web/agent-inbox-persistence.d.ts +52 -0
  133. package/dist/web/agent-inbox-persistence.d.ts.map +1 -0
  134. package/dist/web/agent-inbox-persistence.js +139 -0
  135. package/dist/web/agent-inbox-persistence.js.map +1 -0
  136. package/dist/web/agent-inbox.d.ts +140 -0
  137. package/dist/web/agent-inbox.d.ts.map +1 -0
  138. package/dist/web/agent-inbox.js +258 -0
  139. package/dist/web/agent-inbox.js.map +1 -0
  140. package/dist/web/middleware/auth.d.ts +2 -0
  141. package/dist/web/middleware/auth.d.ts.map +1 -1
  142. package/dist/web/middleware/auth.js +145 -52
  143. package/dist/web/middleware/auth.js.map +1 -1
  144. package/dist/web/network-auth-policy.d.ts +13 -3
  145. package/dist/web/network-auth-policy.d.ts.map +1 -1
  146. package/dist/web/network-auth-policy.js +15 -3
  147. package/dist/web/network-auth-policy.js.map +1 -1
  148. package/dist/web/ports/infra-port.d.ts +2 -0
  149. package/dist/web/ports/infra-port.d.ts.map +1 -1
  150. package/dist/web/public/admin-ui.js.gz +0 -0
  151. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  152. package/dist/web/public/app.3093527f.js +43 -0
  153. package/dist/web/public/app.3093527f.js.br +0 -0
  154. package/dist/web/public/app.3093527f.js.gz +0 -0
  155. package/dist/web/public/approvals-ui.js.gz +0 -0
  156. package/dist/web/public/{constants.4866ecc3.js → constants.affcdde6.js} +622 -12
  157. package/dist/web/public/constants.affcdde6.js.br +0 -0
  158. package/dist/web/public/constants.affcdde6.js.gz +0 -0
  159. package/dist/web/public/cron-ui.js.gz +0 -0
  160. package/dist/web/public/entrance-animations.js.gz +0 -0
  161. package/dist/web/public/git-status-ui.js +88 -5
  162. package/dist/web/public/git-status-ui.js.br +0 -0
  163. package/dist/web/public/git-status-ui.js.gz +0 -0
  164. package/dist/web/public/header-divider.eb2d2463.js +1 -0
  165. package/dist/web/public/header-divider.eb2d2463.js.br +0 -0
  166. package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
  167. package/dist/web/public/home-sessions.js.gz +0 -0
  168. package/dist/web/public/host-wake-ui.js.gz +0 -0
  169. package/dist/web/public/i18n.70265257.js +2 -0
  170. package/dist/web/public/i18n.70265257.js.br +0 -0
  171. package/dist/web/public/i18n.70265257.js.gz +0 -0
  172. package/dist/web/public/image-input.941386e7.js.gz +0 -0
  173. package/dist/web/public/index.html +170 -16
  174. package/dist/web/public/index.html.br +0 -0
  175. package/dist/web/public/index.html.gz +0 -0
  176. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  177. package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
  178. package/dist/web/public/{mobile-handlers.44adaa07.js → mobile-handlers.f7c3f692.js} +26 -2
  179. package/dist/web/public/mobile-handlers.f7c3f692.js.br +0 -0
  180. package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
  181. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  182. package/dist/web/public/mobile-overview.js.gz +0 -0
  183. package/dist/web/public/mobile.1bbd2d67.css +1 -0
  184. package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
  185. package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
  186. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  187. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  188. package/dist/web/public/{panels-ui.cbb24ba1.js → panels-ui.4a56ee63.js} +76 -70
  189. package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
  190. package/dist/web/public/panels-ui.4a56ee63.js.gz +0 -0
  191. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  192. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  193. package/dist/web/public/readmymind-ui.js.gz +0 -0
  194. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  195. package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
  196. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  197. package/dist/web/public/session-lineage.js.gz +0 -0
  198. package/dist/web/public/session-ui.186f9419.js +106 -0
  199. package/dist/web/public/session-ui.186f9419.js.br +0 -0
  200. package/dist/web/public/session-ui.186f9419.js.gz +0 -0
  201. package/dist/web/public/settings-ui.a191154e.js +106 -0
  202. package/dist/web/public/settings-ui.a191154e.js.br +0 -0
  203. package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
  204. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  205. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  206. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  207. package/dist/web/public/styles.7238eae8.css +1 -0
  208. package/dist/web/public/styles.7238eae8.css.br +0 -0
  209. package/dist/web/public/styles.7238eae8.css.gz +0 -0
  210. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  211. package/dist/web/public/sw.js +2 -2
  212. package/dist/web/public/sw.js.br +0 -0
  213. package/dist/web/public/sw.js.gz +0 -0
  214. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  215. package/dist/web/public/{tab-rail-resize.7157e0bd.js → tab-rail-resize.b5cdceaf.js} +1 -1
  216. package/dist/web/public/tab-rail-resize.b5cdceaf.js.br +0 -0
  217. package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
  218. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  219. package/dist/web/public/terminal-split.js.gz +0 -0
  220. package/dist/web/public/terminal-tile.js +10 -1
  221. package/dist/web/public/terminal-tile.js.br +0 -0
  222. package/dist/web/public/terminal-tile.js.gz +0 -0
  223. package/dist/web/public/terminal-ui.7e787829.js +4 -0
  224. package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
  225. package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
  226. package/dist/web/public/tile-grid.js.gz +0 -0
  227. package/dist/web/public/ultracode-panel.js.gz +0 -0
  228. package/dist/web/public/ultracode-windows.js.gz +0 -0
  229. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  230. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  231. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  232. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  233. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  234. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  235. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  236. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  237. package/dist/web/public/vendor/{xterm-predictive-echo.ae5e3d5d.js → xterm-predictive-echo.276b11d9.js} +1 -1
  238. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.br +0 -0
  239. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
  240. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js +5 -0
  241. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.br +0 -0
  242. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
  243. package/dist/web/public/vendor/xterm.css.gz +0 -0
  244. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  245. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  246. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  247. package/dist/web/public/webview-tabs.js.gz +0 -0
  248. package/dist/web/public/whats-new.json +9 -9
  249. package/dist/web/route-helpers.d.ts +57 -6
  250. package/dist/web/route-helpers.d.ts.map +1 -1
  251. package/dist/web/route-helpers.js +72 -4
  252. package/dist/web/route-helpers.js.map +1 -1
  253. package/dist/web/routes/agent-session-routes.d.ts +20 -0
  254. package/dist/web/routes/agent-session-routes.d.ts.map +1 -0
  255. package/dist/web/routes/agent-session-routes.js +160 -0
  256. package/dist/web/routes/agent-session-routes.js.map +1 -0
  257. package/dist/web/routes/case-routes.d.ts.map +1 -1
  258. package/dist/web/routes/case-routes.js +203 -16
  259. package/dist/web/routes/case-routes.js.map +1 -1
  260. package/dist/web/routes/config-backup-routes.js +2 -2
  261. package/dist/web/routes/config-backup-routes.js.map +1 -1
  262. package/dist/web/routes/git-status-routes.d.ts +21 -7
  263. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  264. package/dist/web/routes/git-status-routes.js +78 -12
  265. package/dist/web/routes/git-status-routes.js.map +1 -1
  266. package/dist/web/routes/inbox-routes.d.ts +43 -0
  267. package/dist/web/routes/inbox-routes.d.ts.map +1 -0
  268. package/dist/web/routes/inbox-routes.js +148 -0
  269. package/dist/web/routes/inbox-routes.js.map +1 -0
  270. package/dist/web/routes/index.d.ts +2 -0
  271. package/dist/web/routes/index.d.ts.map +1 -1
  272. package/dist/web/routes/index.js +2 -0
  273. package/dist/web/routes/index.js.map +1 -1
  274. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  275. package/dist/web/routes/ralph-routes.js +7 -16
  276. package/dist/web/routes/ralph-routes.js.map +1 -1
  277. package/dist/web/routes/session-routes.d.ts.map +1 -1
  278. package/dist/web/routes/session-routes.js +120 -74
  279. package/dist/web/routes/session-routes.js.map +1 -1
  280. package/dist/web/routes/system-routes.d.ts.map +1 -1
  281. package/dist/web/routes/system-routes.js +107 -59
  282. package/dist/web/routes/system-routes.js.map +1 -1
  283. package/dist/web/routes/ws-routes.d.ts +5 -0
  284. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  285. package/dist/web/routes/ws-routes.js +84 -47
  286. package/dist/web/routes/ws-routes.js.map +1 -1
  287. package/dist/web/schemas.d.ts +56 -7
  288. package/dist/web/schemas.d.ts.map +1 -1
  289. package/dist/web/schemas.js +88 -1
  290. package/dist/web/schemas.js.map +1 -1
  291. package/dist/web/sensitive-path.d.ts.map +1 -1
  292. package/dist/web/sensitive-path.js +2 -0
  293. package/dist/web/sensitive-path.js.map +1 -1
  294. package/dist/web/server.d.ts +7 -2
  295. package/dist/web/server.d.ts.map +1 -1
  296. package/dist/web/server.js +144 -16
  297. package/dist/web/server.js.map +1 -1
  298. package/dist/web/session-wait-registry.d.ts +48 -4
  299. package/dist/web/session-wait-registry.d.ts.map +1 -1
  300. package/dist/web/session-wait-registry.js +144 -6
  301. package/dist/web/session-wait-registry.js.map +1 -1
  302. package/dist/web/settings-writer.d.ts +47 -0
  303. package/dist/web/settings-writer.d.ts.map +1 -0
  304. package/dist/web/settings-writer.js +110 -0
  305. package/dist/web/settings-writer.js.map +1 -0
  306. package/dist/web/sse-events.d.ts +4 -1
  307. package/dist/web/sse-events.d.ts.map +1 -1
  308. package/dist/web/sse-events.js +4 -1
  309. package/dist/web/sse-events.js.map +1 -1
  310. package/package.json +1 -1
  311. package/skills/codeman/SKILL.md +30 -8
  312. package/skills/codeman/preamble.sh +3 -3
  313. package/skills/codeman/reference/endpoints.md +24 -3
  314. package/skills/codeman/reference/history.md +84 -0
  315. package/skills/codeman/reference/inspection-and-staging.md +152 -0
  316. package/skills/codeman/reference/messaging.md +1 -1
  317. package/skills/codeman/reference/verbs.md +65 -3
  318. package/dist/web/public/app.9314ee60.js +0 -43
  319. package/dist/web/public/app.9314ee60.js.br +0 -0
  320. package/dist/web/public/app.9314ee60.js.gz +0 -0
  321. package/dist/web/public/constants.4866ecc3.js.br +0 -0
  322. package/dist/web/public/constants.4866ecc3.js.gz +0 -0
  323. package/dist/web/public/i18n.22ca902b.js +0 -2
  324. package/dist/web/public/i18n.22ca902b.js.br +0 -0
  325. package/dist/web/public/i18n.22ca902b.js.gz +0 -0
  326. package/dist/web/public/mobile-handlers.44adaa07.js.br +0 -0
  327. package/dist/web/public/mobile-handlers.44adaa07.js.gz +0 -0
  328. package/dist/web/public/mobile.12fb18de.css +0 -1
  329. package/dist/web/public/mobile.12fb18de.css.br +0 -0
  330. package/dist/web/public/mobile.12fb18de.css.gz +0 -0
  331. package/dist/web/public/panels-ui.cbb24ba1.js.br +0 -0
  332. package/dist/web/public/panels-ui.cbb24ba1.js.gz +0 -0
  333. package/dist/web/public/session-ui.9c1f009b.js +0 -104
  334. package/dist/web/public/session-ui.9c1f009b.js.br +0 -0
  335. package/dist/web/public/session-ui.9c1f009b.js.gz +0 -0
  336. package/dist/web/public/settings-ui.fea9ddae.js +0 -106
  337. package/dist/web/public/settings-ui.fea9ddae.js.br +0 -0
  338. package/dist/web/public/settings-ui.fea9ddae.js.gz +0 -0
  339. package/dist/web/public/styles.e096fcd6.css +0 -1
  340. package/dist/web/public/styles.e096fcd6.css.br +0 -0
  341. package/dist/web/public/styles.e096fcd6.css.gz +0 -0
  342. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  343. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  344. package/dist/web/public/terminal-ui.68dbd352.js +0 -2
  345. package/dist/web/public/terminal-ui.68dbd352.js.br +0 -0
  346. package/dist/web/public/terminal-ui.68dbd352.js.gz +0 -0
  347. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.br +0 -0
  348. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  349. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +0 -4
  350. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  351. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * @fileoverview Agent inbox: a per-session mailbox other agents (or the user) post to,
3
+ * and the receiving agent reads when it chooses to.
4
+ *
5
+ * The other agent-to-agent channel, `POST /api/sessions/:id/input`, types into the
6
+ * receiver's composer. That is right for a prompt and wrong for a message: every
7
+ * delivery starts or interrupts a turn, the receiver cannot defer it, and it is one
8
+ * line only. The inbox stores instead of typing. Nothing here ever reaches a pane.
9
+ *
10
+ * Invariants (each pinned in `test/agent-inbox.test.ts`):
11
+ * - **Read until ack.** A read hands out every pending message and removes nothing.
12
+ * A message goes only when the receiver acknowledges its id. "Read and dropped"
13
+ * and "done" must not look the same to the sender (discussion #558, the 09-23 case).
14
+ * - **Seen is the receiver's alone.** `read(…, { receiver: true })` marks what it
15
+ * handed out as seen; any other read is a peek and changes nothing. The route
16
+ * decides who the receiver is; this module only takes the flag.
17
+ * - **Bounded, and full refuses.** `MAX_MESSAGES_PER_INBOX`, `MAX_MESSAGE_BYTES` and
18
+ * `MAX_INBOX_BYTES`. A post that would cross any of them is refused so the sender
19
+ * learns. The oldest message is never dropped to make room.
20
+ * - **TTL from the message's own timestamp** (`MESSAGE_TTL_MS`). A restore keeps
21
+ * `createdAt`, so a restart does not restart the clock. Expiry is lazy: every
22
+ * accessor sweeps the inbox it touches, `summary()` and `snapshot()` sweep all.
23
+ * - **No IO, no `Session` import.** Persistence (`agent-inbox-persistence.ts`) hooks
24
+ * `onChange`; long-poll waiters live in `session-wait-registry.ts`.
25
+ *
26
+ * @consumedby web/routes/inbox-routes, web/agent-inbox-persistence, web/server
27
+ * @module web/agent-inbox
28
+ */
29
+ import { randomUUID } from 'node:crypto';
30
+ // ─── Bounds ──────────────────────────────────────────────────────────────────
31
+ /** Messages one inbox holds before posts are refused. */
32
+ export const MAX_MESSAGES_PER_INBOX = 200;
33
+ /** UTF-8 bytes per message. A bigger payload belongs in the workspace; post its path. */
34
+ export const MAX_MESSAGE_BYTES = 16_384;
35
+ /** UTF-8 bytes of message text one inbox holds before posts are refused. */
36
+ export const MAX_INBOX_BYTES = 512 * 1024;
37
+ /** A message older than this is gone, read or not (same span as the approvals inbox). */
38
+ export const MESSAGE_TTL_MS = 12 * 60 * 60 * 1000;
39
+ // ─── Store ───────────────────────────────────────────────────────────────────
40
+ export class AgentInbox {
41
+ now;
42
+ inboxes = new Map();
43
+ /** Any mutation (post, seen, ack, expiry, drop, prune, restore excluded). Persistence hooks this. */
44
+ onChange;
45
+ constructor(now = Date.now) {
46
+ this.now = now;
47
+ }
48
+ /**
49
+ * Store a message for `sessionId`. `from` is the RESOLVED sender (the route's job),
50
+ * never a client-supplied label.
51
+ */
52
+ post(sessionId, from, text) {
53
+ if (text.length === 0)
54
+ return { ok: false, reason: 'empty' };
55
+ const bytes = Buffer.byteLength(text, 'utf8');
56
+ if (bytes > MAX_MESSAGE_BYTES)
57
+ return { ok: false, reason: 'message-too-large' };
58
+ const queue = this.live(sessionId);
59
+ if (queue.length >= MAX_MESSAGES_PER_INBOX)
60
+ return { ok: false, reason: 'inbox-full-count' };
61
+ if (inboxBytes(queue) + bytes > MAX_INBOX_BYTES)
62
+ return { ok: false, reason: 'inbox-full-bytes' };
63
+ const stored = { id: randomUUID(), from, text, createdAt: this.now(), seenAt: null };
64
+ queue.push(stored);
65
+ this.inboxes.set(sessionId, queue);
66
+ this.onChange?.();
67
+ return { ok: true, message: view(stored), pending: queue.length, unseen: countUnseen(queue) };
68
+ }
69
+ /**
70
+ * Every pending message, oldest first. Removes nothing. With `receiver`, the
71
+ * messages handed out are marked seen (each keeps its first `seenAt`); without it
72
+ * this is a peek and changes nothing at all.
73
+ */
74
+ read(sessionId, options) {
75
+ const queue = this.live(sessionId);
76
+ const messages = queue.map(view);
77
+ if (options.receiver) {
78
+ let marked = false;
79
+ const at = this.now();
80
+ for (const m of queue) {
81
+ if (m.seenAt !== null)
82
+ continue;
83
+ m.seenAt = at;
84
+ marked = true;
85
+ }
86
+ if (marked)
87
+ this.onChange?.();
88
+ }
89
+ return { messages, pending: queue.length, unseen: countUnseen(queue) };
90
+ }
91
+ /** Messages no receiver read has handed out yet. A `?wait=` read parks while this is 0. */
92
+ unseenCount(sessionId) {
93
+ return countUnseen(this.live(sessionId));
94
+ }
95
+ pendingCount(sessionId) {
96
+ return this.live(sessionId).length;
97
+ }
98
+ /**
99
+ * Remove the given messages. Idempotent: an id that is unknown or already gone is
100
+ * not an error, so a retry after a lost response succeeds. Returns how many went.
101
+ */
102
+ ack(sessionId, ids) {
103
+ const queue = this.live(sessionId);
104
+ if (queue.length === 0 || ids.length === 0)
105
+ return 0;
106
+ const drop = new Set(ids);
107
+ const kept = queue.filter((m) => !drop.has(m.id));
108
+ const removed = queue.length - kept.length;
109
+ if (removed === 0)
110
+ return 0;
111
+ this.store(sessionId, kept);
112
+ this.onChange?.();
113
+ return removed;
114
+ }
115
+ /** Per-session counts for every inbox that holds anything. Never blocks. */
116
+ summary() {
117
+ const out = new Map();
118
+ for (const sessionId of [...this.inboxes.keys()]) {
119
+ const queue = this.live(sessionId);
120
+ if (queue.length === 0)
121
+ continue;
122
+ const unseen = queue.filter((m) => m.seenAt === null);
123
+ out.set(sessionId, {
124
+ pending: queue.length,
125
+ unseen: unseen.length,
126
+ oldestUnseenAt: unseen.length > 0 ? unseen[0].createdAt : null,
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+ /** The session was DELETED: its mail goes with it. Not called on detach or shutdown. */
132
+ drop(sessionId) {
133
+ if (this.inboxes.delete(sessionId))
134
+ this.onChange?.();
135
+ }
136
+ /**
137
+ * Boot-time sweep, run only after the session restore succeeded: discard inboxes
138
+ * whose session did not come back. Every route needs a live session, so an orphan
139
+ * could otherwise never be removed. Returns how many inboxes were dropped.
140
+ */
141
+ pruneExcept(keep) {
142
+ const alive = new Set(keep);
143
+ let dropped = 0;
144
+ for (const sessionId of [...this.inboxes.keys()]) {
145
+ if (alive.has(sessionId))
146
+ continue;
147
+ this.inboxes.delete(sessionId);
148
+ dropped++;
149
+ }
150
+ if (dropped > 0)
151
+ this.onChange?.();
152
+ return dropped;
153
+ }
154
+ /** Whole-store snapshot for persistence, expired messages already swept. */
155
+ snapshot() {
156
+ const inboxes = {};
157
+ for (const sessionId of [...this.inboxes.keys()]) {
158
+ const queue = this.live(sessionId);
159
+ if (queue.length > 0)
160
+ inboxes[sessionId] = queue.map((m) => ({ ...m }));
161
+ }
162
+ return { version: 1, inboxes };
163
+ }
164
+ /**
165
+ * Replace the store from a snapshot (server boot). Malformed input is skipped entry
166
+ * by entry rather than thrown: a corrupt file must not stop the server. Expired
167
+ * messages are not restored, and the caps hold for what is. Does not fire
168
+ * `onChange`: writing back what was just read would be a no-op at best.
169
+ */
170
+ restore(snapshot) {
171
+ const data = snapshot;
172
+ this.inboxes.clear();
173
+ if (!data || data.version !== 1 || typeof data.inboxes !== 'object' || data.inboxes === null)
174
+ return 0;
175
+ const cutoff = this.now() - MESSAGE_TTL_MS;
176
+ let restored = 0;
177
+ for (const [sessionId, raw] of Object.entries(data.inboxes)) {
178
+ if (!Array.isArray(raw))
179
+ continue;
180
+ const queue = [];
181
+ let bytes = 0;
182
+ // Oldest first whatever order the file has, so the caps keep the oldest mail,
183
+ // which is the rule for posts too.
184
+ const entries = raw
185
+ .filter((entry) => isStoredMessage(entry) && entry.createdAt > cutoff)
186
+ .sort((a, b) => a.createdAt - b.createdAt);
187
+ for (const entry of entries) {
188
+ const size = Buffer.byteLength(entry.text, 'utf8');
189
+ if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES)
190
+ break;
191
+ queue.push({ ...entry });
192
+ bytes += size;
193
+ }
194
+ if (queue.length === 0)
195
+ continue;
196
+ this.inboxes.set(sessionId, queue);
197
+ restored += queue.length;
198
+ }
199
+ return restored;
200
+ }
201
+ /** Test hook: back to an empty store with no listener. */
202
+ resetForTests() {
203
+ this.inboxes.clear();
204
+ this.onChange = undefined;
205
+ }
206
+ /** The inbox with expired messages swept out (fires `onChange` when it swept any). */
207
+ live(sessionId) {
208
+ const queue = this.inboxes.get(sessionId);
209
+ if (!queue)
210
+ return [];
211
+ const cutoff = this.now() - MESSAGE_TTL_MS;
212
+ // Every message, not only queue[0]: a clock stepping back can put an older
213
+ // createdAt behind a newer one. At most 200 entries, so the scan is cheap.
214
+ if (!queue.some((m) => m.createdAt <= cutoff))
215
+ return queue;
216
+ const kept = queue.filter((m) => m.createdAt > cutoff);
217
+ this.store(sessionId, kept);
218
+ this.onChange?.();
219
+ return kept;
220
+ }
221
+ store(sessionId, queue) {
222
+ if (queue.length === 0)
223
+ this.inboxes.delete(sessionId);
224
+ else
225
+ this.inboxes.set(sessionId, queue);
226
+ }
227
+ }
228
+ function view(m) {
229
+ return { id: m.id, from: m.from, text: m.text, createdAt: m.createdAt, seen: m.seenAt !== null };
230
+ }
231
+ function countUnseen(queue) {
232
+ let n = 0;
233
+ for (const m of queue)
234
+ if (m.seenAt === null)
235
+ n++;
236
+ return n;
237
+ }
238
+ function inboxBytes(queue) {
239
+ let total = 0;
240
+ for (const m of queue)
241
+ total += Buffer.byteLength(m.text, 'utf8');
242
+ return total;
243
+ }
244
+ function isStoredMessage(value) {
245
+ const m = value;
246
+ return (!!m &&
247
+ typeof m.id === 'string' &&
248
+ (m.from === null || typeof m.from === 'string') &&
249
+ typeof m.text === 'string' &&
250
+ m.text.length > 0 &&
251
+ Buffer.byteLength(m.text, 'utf8') <= MAX_MESSAGE_BYTES &&
252
+ typeof m.createdAt === 'number' &&
253
+ Number.isFinite(m.createdAt) &&
254
+ (m.seenAt === null || typeof m.seenAt === 'number'));
255
+ }
256
+ /** Process-wide store used by the routes and the server wiring. Tests construct their own. */
257
+ export const agentInbox = new AgentInbox();
258
+ //# sourceMappingURL=agent-inbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-inbox.js","sourceRoot":"","sources":["../../src/web/agent-inbox.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,8 @@ interface AuthState {
26
26
  hookSecretFailures: StaleExpirationMap<string, number> | null;
27
27
  /** Per-username Basic-auth failure bucket (multi-user only). */
28
28
  userFailures: StaleExpirationMap<string, number> | null;
29
+ /** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
30
+ userIpFailures: StaleExpirationMap<string, number> | null;
29
31
  }
30
32
  /**
31
33
  * The landing-page case of serveLostWebviewFrame, for the index route. Without
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/web/middleware/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAgD,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAoB1G,OAAO,EAAqC,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAIlF,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,cAAc;QACtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;KACrB;CACF;AAGD,eAAO,MAAM,gBAAgB,oBAAoB,CAAC;AAElD,8EAA8E;AAC9E,UAAU,SAAS;IACjB,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAAG,IAAI,CAAC;IACnE,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IACxD,cAAc,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IAC1D,kBAAkB,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IAC9D,gEAAgE;IAChE,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACzD;AAwLD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAInE;AAED,uFAAuF;AACvF,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,YAAY,GAAG,YAAY,CAI1E;AAgCD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,SAAK,GAAG,SAAS,CAoKrG;AA8KD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,EAAE,QAAQ,SAAK,GAAG,IAAI,CAuBxG;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,SAAK,GAAG,IAAI,CAwEjG"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/web/middleware/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAgD,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAqB1G,OAAO,EAAqC,KAAK,QAAQ,EAAmB,MAAM,gBAAgB,CAAC;AAInG,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,cAAc;QACtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;KACrB;CACF;AAGD,eAAO,MAAM,gBAAgB,oBAAoB,CAAC;AAElD,8EAA8E;AAC9E,UAAU,SAAS;IACjB,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAAG,IAAI,CAAC;IACnE,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IACxD,cAAc,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IAC1D,kBAAkB,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IAC9D,gEAAgE;IAChE,YAAY,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;IACxD,6EAA6E;IAC7E,cAAc,EAAE,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CAC3D;AAwMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAInE;AAED,uFAAuF;AACvF,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,YAAY,GAAG,YAAY,CAI1E;AAgCD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,SAAK,GAAG,SAAS,CAyLrG;AAgQD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,EAAE,QAAQ,SAAK,GAAG,IAAI,CAuBxG;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,SAAK,GAAG,IAAI,CAwEjG"}
@@ -7,10 +7,10 @@
7
7
  * - Security headers (CSP, X-Frame-Options, HSTS)
8
8
  * - CORS (localhost only)
9
9
  */
10
- import { randomBytes, timingSafeEqual } from 'node:crypto';
10
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
11
11
  import { StaleExpirationMap } from '../../utils/index.js';
12
12
  import { isAllowedRequestHost, isAllowedRequestOrigin } from '../network-auth-policy.js';
13
- import { AUTH_SESSION_TTL_MS, MAX_AUTH_SESSIONS, AUTH_FAILURE_MAX, AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js';
13
+ import { AUTH_SESSION_TTL_MS, MAX_AUTH_SESSIONS, AUTH_FAILURE_MAX, AUTH_IP_FAILURE_MAX, AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js';
14
14
  import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
15
15
  import { isMultiUserMode } from '../../config/multiuser.js';
16
16
  import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
@@ -42,6 +42,18 @@ function parseBasicAuth(header) {
42
42
  return null;
43
43
  }
44
44
  }
45
+ /**
46
+ * Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
47
+ * normalized username, password), so the plaintext password is never held as a key.
48
+ * The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
49
+ * string is not, since a username may carry a NUL). Never logged or persisted, and
50
+ * gone from the map once its verify settles.
51
+ */
52
+ function verifyFlightKey(clientIp, normUser, password) {
53
+ return createHash('sha256')
54
+ .update(JSON.stringify([clientIp, normUser, password]))
55
+ .digest('hex');
56
+ }
45
57
  /**
46
58
  * The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
47
59
  * single-user and multi-user auth hooks so the security-critical logic has ONE
@@ -266,6 +278,7 @@ export function registerAuthMiddleware(app, https, basePath = '') {
266
278
  qrAuthFailures: null,
267
279
  hookSecretFailures: null,
268
280
  userFailures: null,
281
+ userIpFailures: null,
269
282
  };
270
283
  // Always declare req.authUser so downstream reads are safe (single-user leaves it
271
284
  // undefined; the ownership helpers then default to a synthetic admin).
@@ -306,13 +319,24 @@ export function registerAuthMiddleware(app, https, basePath = '') {
306
319
  const authFailures = state.authFailures;
307
320
  const hookSecretFailures = state.hookSecretFailures;
308
321
  if (multiUser) {
309
- // Per-username failure bucket: a botnet can't brute-force one account across
310
- // many IPs, and one user behind a NAT can't lock out everyone else.
322
+ // Per-username failure bucket: failed guesses against one account answer 429
323
+ // from any IP once it fills, and one user behind a NAT can't lock out everyone
324
+ // else. Checked after the verify, so it never denies a correct password.
311
325
  state.userFailures = new StaleExpirationMap({
312
326
  ttlMs: AUTH_FAILURE_WINDOW_MS,
313
327
  refreshOnGet: false,
314
328
  });
315
- registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
329
+ // Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
330
+ // caps verified guesses against one account from one source. Keyed on the pair,
331
+ // so other IPs can't lock the account out and two users behind one shared IP
332
+ // don't lock each other out. Same decay as the other buckets: it holds one key
333
+ // per (IP, username) pair whose verify failed or is still in flight, swept by
334
+ // the same TTL, so like them it grows only with scrypt verifies.
335
+ state.userIpFailures = new StaleExpirationMap({
336
+ ttlMs: AUTH_FAILURE_WINDOW_MS,
337
+ refreshOnGet: false,
338
+ });
339
+ registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, state.userIpFailures, basePath);
316
340
  return state;
317
341
  }
318
342
  // ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
@@ -408,10 +432,11 @@ export function registerAuthMiddleware(app, https, basePath = '') {
408
432
  /**
409
433
  * Multi-user auth hook (async, because password verification runs scrypt). Verifies
410
434
  * `username:password` against the user store, mints an identity-carrying cookie,
411
- * decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
412
- * `mustChangePassword` lockbox. The single-user hook above is left untouched.
435
+ * decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
436
+ * rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
437
+ * share one verify. The single-user hook above is left untouched.
413
438
  */
414
- function registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, userFailures, basePath = '') {
439
+ function registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, userFailures, userIpFailures, basePath = '') {
415
440
  const setSessionCookie = (reply, token) => reply.setCookie(AUTH_COOKIE_NAME, token, {
416
441
  httpOnly: true,
417
442
  secure: https,
@@ -447,6 +472,63 @@ function registerMultiUserAuthHook(app, https, authSessions, authFailures, hookS
447
472
  }
448
473
  return false;
449
474
  };
475
+ // Basic verifies in flight, by verifyFlightKey(). A request whose identical
476
+ // credentials from the same IP are already being verified awaits that verify
477
+ // instead of starting its own. Each entry is removed when its verify settles, so the
478
+ // map never holds more than the distinct verifies in flight.
479
+ const verifiesInFlight = new Map();
480
+ // The verify plus its once-only side effects, run by the first request for a key.
481
+ // A success clears the three buckets and records the login; a failure is counted
482
+ // here once (the per-IP and pair buckets already hold it: the caller reserved them).
483
+ const verifyOnce = async (username, password, normUser, clientIp, pairKey) => {
484
+ const result = await verifyPassword(username, password);
485
+ if (result) {
486
+ const { user, needsRehash: rehash } = result;
487
+ if (rehash)
488
+ void setPassword(user.username, password).catch(() => { });
489
+ void touchLastLogin(user.username).catch(() => { });
490
+ authFailures.delete(clientIp);
491
+ userFailures.delete(normUser);
492
+ userIpFailures.delete(pairKey);
493
+ return { user };
494
+ }
495
+ // Failed guess: only the per-username bucket counts it here. Once that bucket
496
+ // reaches the cap, further FAILED attempts get 429 from any IP, but this path is
497
+ // only reached on a wrong password, so it can never deny a correct one. Its count
498
+ // is re-read after the await, so concurrent failures against one account each add one.
499
+ const uFail = (userFailures.get(normUser) ?? 0) + 1;
500
+ userFailures.set(normUser, uFail);
501
+ return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
502
+ };
503
+ // Answer one request from a shared outcome. Every successful request, joiners
504
+ // included, gets its own cookie session under the usual capacity rules.
505
+ const sendBasicOutcome = (req, reply, outcome, normUser) => {
506
+ if (outcome.user) {
507
+ const { user } = outcome;
508
+ const token = randomBytes(32).toString('hex');
509
+ if (authSessions.size >= MAX_AUTH_SESSIONS)
510
+ evictForCapacity(user.username);
511
+ authSessions.set(token, {
512
+ ip: req.ip,
513
+ ua: req.headers['user-agent'] ?? '',
514
+ createdAt: Date.now(),
515
+ method: 'basic',
516
+ username: user.username,
517
+ role: user.role,
518
+ mustChangePassword: !!user.mustChangePassword,
519
+ });
520
+ req.authUser = { username: user.username, role: user.role };
521
+ setSessionCookie(reply, token);
522
+ enforcePasswordChange(req, reply, !!user.mustChangePassword);
523
+ return;
524
+ }
525
+ if (outcome.limited) {
526
+ sendAuthRateLimit(reply, userFailures, normUser);
527
+ return;
528
+ }
529
+ reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
530
+ reply.code(401).send('Unauthorized');
531
+ };
450
532
  app.addHook('onRequest', async (req, reply) => {
451
533
  const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
452
534
  if (bypass === 'bypass' || bypass === 'rejected')
@@ -502,59 +584,70 @@ function registerMultiUserAuthHook(app, https, authSessions, authFailures, hookS
502
584
  enforcePasswordChange(req, reply, !!live.mustChangePassword);
503
585
  return;
504
586
  }
505
- // 2. Basic Auth against the user store (scrypt verify).
506
- // Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
507
- // per-username bucket here; see below).
587
+ // 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
588
+ // - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
589
+ // source. It refuses even a correct password, so it is wider than
590
+ // AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
591
+ // would let a few wrong guesses lock everyone out.
592
+ // - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
593
+ // verified guesses against one account from one source. Only that IP is
594
+ // refused for that account; the same account from another IP still logs in.
595
+ // - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
596
+ // Neither pre-verify refusal is counted, since nothing was verified (and counting
597
+ // a locked pair's retries would fill the shared per-IP bucket for everyone).
598
+ // A request that passes both gates RESERVES its slot in both buckets before the
599
+ // scrypt await, as if it had already failed. Counting only after the verify let a
600
+ // concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
601
+ // right password, all in flight at once from one IP, logged in. A success deletes
602
+ // the reservation with the rest of the buckets; a failure is not counted again.
603
+ // A request whose IDENTICAL credentials from the same IP are already being verified
604
+ // joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
605
+ // gets the same outcome, since it adds no guess and no CPU. Reserving for each of
606
+ // them refused a correct password with the slots that password itself had filled,
607
+ // which is every reload after a restart: cookie sessions live in memory, so the
608
+ // browser replays its cached Basic header on many parallel requests at once. A
609
+ // request with no verify in flight for its credentials is gated as normal.
610
+ const creds = parseBasicAuth(req.headers.authorization);
611
+ const normUser = creds ? creds.username.trim().toLowerCase() : '';
612
+ const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
613
+ const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
614
+ if (shared) {
615
+ sendBasicOutcome(req, reply, await shared, normUser);
616
+ return;
617
+ }
508
618
  const ipFail = authFailures.get(clientIp) ?? 0;
509
- if (ipFail >= AUTH_FAILURE_MAX) {
619
+ if (ipFail >= AUTH_IP_FAILURE_MAX) {
510
620
  sendAuthRateLimit(reply, authFailures, clientIp);
511
621
  return;
512
622
  }
513
- const creds = parseBasicAuth(req.headers.authorization);
514
623
  if (creds) {
515
- const normUser = creds.username.trim().toLowerCase();
516
- // Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
517
- // per-username bucket before verifying let throwaway IPs lock out a known account
518
- // (incl. admin) even with the correct password. A correct password must always
519
- // win and self-heal both buckets, regardless of the username-failure count.
520
- const result = await verifyPassword(creds.username, creds.password);
521
- if (result) {
522
- const { user, needsRehash: rehash } = result;
523
- if (rehash)
524
- void setPassword(user.username, creds.password).catch(() => { });
525
- void touchLastLogin(user.username).catch(() => { });
526
- authFailures.delete(clientIp);
527
- userFailures.delete(normUser);
528
- const token = randomBytes(32).toString('hex');
529
- if (authSessions.size >= MAX_AUTH_SESSIONS)
530
- evictForCapacity(user.username);
531
- authSessions.set(token, {
532
- ip: clientIp,
533
- ua: req.headers['user-agent'] ?? '',
534
- createdAt: Date.now(),
535
- method: 'basic',
536
- username: user.username,
537
- role: user.role,
538
- mustChangePassword: !!user.mustChangePassword,
539
- });
540
- req.authUser = { username: user.username, role: user.role };
541
- setSessionCookie(reply, token);
542
- enforcePasswordChange(req, reply, !!user.mustChangePassword);
624
+ // IPs never contain a newline, so this key is unambiguous for any username.
625
+ const pairKey = `${clientIp}\n${normUser}`;
626
+ const pairFail = userIpFailures.get(pairKey) ?? 0;
627
+ if (pairFail >= AUTH_FAILURE_MAX) {
628
+ sendAuthRateLimit(reply, userIpFailures, pairKey);
543
629
  return;
544
630
  }
545
- // Failed guess: count it against BOTH buckets. Once the per-username bucket
546
- // reaches the cap, further FAILED attempts get 429 (throttles distributed
547
- // brute-force), but this path is only reached on a wrong password, so it can
548
- // never deny a correct one.
549
- const uFail = (userFailures.get(normUser) ?? 0) + 1;
550
- userFailures.set(normUser, uFail);
631
+ // Reserve both slots and publish this verify for joiners, from the values the
632
+ // gates just read. Correct only while nothing between those reads and these
633
+ // writes awaits: keep it that way.
551
634
  authFailures.set(clientIp, ipFail + 1);
552
- if (uFail >= AUTH_FAILURE_MAX) {
553
- sendAuthRateLimit(reply, userFailures, normUser);
554
- return;
635
+ userIpFailures.set(pairKey, pairFail + 1);
636
+ // Security: the per-username bucket is consulted only AFTER the verify, and only
637
+ // FAILED attempts are throttled by it. Consulting it before verifying let
638
+ // throwaway IPs lock out a known account (incl. admin) even with the correct
639
+ // password. A correct password that passes the two gates above always wins and
640
+ // self-heals the per-IP, per-username and this pair's buckets.
641
+ const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
642
+ verifiesInFlight.set(flightKey, verify);
643
+ let outcome;
644
+ try {
645
+ outcome = await verify;
646
+ }
647
+ finally {
648
+ verifiesInFlight.delete(flightKey);
555
649
  }
556
- reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
557
- reply.code(401).send('Unauthorized');
650
+ sendBasicOutcome(req, reply, outcome, normUser);
558
651
  return;
559
652
  }
560
653
  // No credentials presented: count against the per-IP bucket and challenge.