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.
- package/README.md +32 -8
- package/dist/agent-codex-identity.d.ts +26 -0
- package/dist/agent-codex-identity.d.ts.map +1 -0
- package/dist/agent-codex-identity.js +253 -0
- package/dist/agent-codex-identity.js.map +1 -0
- package/dist/agent-inspection.d.ts +32 -0
- package/dist/agent-inspection.d.ts.map +1 -0
- package/dist/agent-inspection.js +196 -0
- package/dist/agent-inspection.js.map +1 -0
- package/dist/agent-staging.d.ts +130 -0
- package/dist/agent-staging.d.ts.map +1 -0
- package/dist/agent-staging.js +312 -0
- package/dist/agent-staging.js.map +1 -0
- package/dist/case-rename.d.ts +65 -0
- package/dist/case-rename.d.ts.map +1 -0
- package/dist/case-rename.js +198 -0
- package/dist/case-rename.js.map +1 -0
- package/dist/cli-agent.d.ts +91 -3
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +501 -44
- package/dist/cli-agent.js.map +1 -1
- package/dist/cli-history.d.ts +20 -0
- package/dist/cli-history.d.ts.map +1 -0
- package/dist/cli-history.js +61 -0
- package/dist/cli-history.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex-rollout-format.d.ts +66 -0
- package/dist/codex-rollout-format.d.ts.map +1 -0
- package/dist/codex-rollout-format.js +111 -0
- package/dist/codex-rollout-format.js.map +1 -0
- package/dist/codex-transcript.d.ts +3 -0
- package/dist/codex-transcript.d.ts.map +1 -1
- package/dist/codex-transcript.js +13 -41
- package/dist/codex-transcript.js.map +1 -1
- package/dist/config/auth-config.d.ts +16 -1
- package/dist/config/auth-config.d.ts.map +1 -1
- package/dist/config/auth-config.js +16 -1
- package/dist/config/auth-config.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +12 -7
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +26 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +40 -1
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +57 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/server-timing.d.ts +6 -0
- package/dist/config/server-timing.d.ts.map +1 -1
- package/dist/config/server-timing.js +9 -0
- package/dist/config/server-timing.js.map +1 -1
- package/dist/daemon-control.d.ts +1 -0
- package/dist/daemon-control.d.ts.map +1 -1
- package/dist/daemon-control.js +2 -0
- package/dist/daemon-control.js.map +1 -1
- package/dist/deepseek-transcript.d.ts +9 -7
- package/dist/deepseek-transcript.d.ts.map +1 -1
- package/dist/deepseek-transcript.js +9 -7
- package/dist/deepseek-transcript.js.map +1 -1
- package/dist/docker-hosts.d.ts +8 -0
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +29 -0
- package/dist/docker-hosts.js.map +1 -1
- package/dist/git-fetch-scheduler.d.ts +124 -0
- package/dist/git-fetch-scheduler.d.ts.map +1 -0
- package/dist/git-fetch-scheduler.js +249 -0
- package/dist/git-fetch-scheduler.js.map +1 -0
- package/dist/git-fetch.d.ts +241 -0
- package/dist/git-fetch.d.ts.map +1 -0
- package/dist/git-fetch.js +392 -0
- package/dist/git-fetch.js.map +1 -0
- package/dist/git-workspace-status.d.ts +36 -1
- package/dist/git-workspace-status.d.ts.map +1 -1
- package/dist/git-workspace-status.js +42 -5
- package/dist/git-workspace-status.js.map +1 -1
- package/dist/hooks-config.d.ts +14 -2
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +39 -3
- package/dist/hooks-config.js.map +1 -1
- package/dist/host-sensors.d.ts +115 -0
- package/dist/host-sensors.d.ts.map +1 -0
- package/dist/host-sensors.js +457 -0
- package/dist/host-sensors.js.map +1 -0
- package/dist/local-history.d.ts +71 -0
- package/dist/local-history.d.ts.map +1 -0
- package/dist/local-history.js +750 -0
- package/dist/local-history.js.map +1 -0
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +2 -2
- package/dist/mcp-server.js.map +1 -1
- package/dist/mux-interface.d.ts +5 -2
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/prompt-cache-keepwarm.d.ts.map +1 -1
- package/dist/prompt-cache-keepwarm.js +5 -3
- package/dist/prompt-cache-keepwarm.js.map +1 -1
- package/dist/remote-hosts.d.ts +7 -0
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +17 -3
- package/dist/remote-hosts.js.map +1 -1
- package/dist/remote-pane-history.d.ts.map +1 -1
- package/dist/remote-pane-history.js +2 -2
- package/dist/remote-pane-history.js.map +1 -1
- package/dist/session.d.ts +113 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +354 -13
- package/dist/session.js.map +1 -1
- package/dist/terminal-input-classifier.d.ts +8 -0
- package/dist/terminal-input-classifier.d.ts.map +1 -0
- package/dist/terminal-input-classifier.js +60 -0
- package/dist/terminal-input-classifier.js.map +1 -0
- package/dist/tmux-manager.d.ts +30 -4
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +101 -21
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-prefix-table.d.ts +37 -5
- package/dist/tmux-prefix-table.d.ts.map +1 -1
- package/dist/tmux-prefix-table.js +40 -5
- package/dist/tmux-prefix-table.js.map +1 -1
- package/dist/tui/tui-app.d.ts +8 -1
- package/dist/tui/tui-app.d.ts.map +1 -1
- package/dist/tui/tui-app.js +49 -12
- package/dist/tui/tui-app.js.map +1 -1
- package/dist/tui/tui-client.d.ts +90 -3
- package/dist/tui/tui-client.d.ts.map +1 -1
- package/dist/tui/tui-client.js +195 -26
- package/dist/tui/tui-client.js.map +1 -1
- package/dist/types/session.d.ts +15 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/agent-inbox-persistence.d.ts +52 -0
- package/dist/web/agent-inbox-persistence.d.ts.map +1 -0
- package/dist/web/agent-inbox-persistence.js +139 -0
- package/dist/web/agent-inbox-persistence.js.map +1 -0
- package/dist/web/agent-inbox.d.ts +140 -0
- package/dist/web/agent-inbox.d.ts.map +1 -0
- package/dist/web/agent-inbox.js +258 -0
- package/dist/web/agent-inbox.js.map +1 -0
- package/dist/web/middleware/auth.d.ts +2 -0
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +145 -52
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/network-auth-policy.d.ts +13 -3
- package/dist/web/network-auth-policy.d.ts.map +1 -1
- package/dist/web/network-auth-policy.js +15 -3
- package/dist/web/network-auth-policy.js.map +1 -1
- package/dist/web/ports/infra-port.d.ts +2 -0
- package/dist/web/ports/infra-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.3093527f.js +43 -0
- package/dist/web/public/app.3093527f.js.br +0 -0
- package/dist/web/public/app.3093527f.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.4866ecc3.js → constants.affcdde6.js} +622 -12
- package/dist/web/public/constants.affcdde6.js.br +0 -0
- package/dist/web/public/constants.affcdde6.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +88 -5
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/header-divider.eb2d2463.js +1 -0
- package/dist/web/public/header-divider.eb2d2463.js.br +0 -0
- package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.70265257.js +2 -0
- package/dist/web/public/i18n.70265257.js.br +0 -0
- package/dist/web/public/i18n.70265257.js.gz +0 -0
- package/dist/web/public/image-input.941386e7.js.gz +0 -0
- package/dist/web/public/index.html +170 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.44adaa07.js → mobile-handlers.f7c3f692.js} +26 -2
- package/dist/web/public/mobile-handlers.f7c3f692.js.br +0 -0
- package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.1bbd2d67.css +1 -0
- package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
- package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.cbb24ba1.js → panels-ui.4a56ee63.js} +76 -70
- package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
- package/dist/web/public/panels-ui.4a56ee63.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.186f9419.js +106 -0
- package/dist/web/public/session-ui.186f9419.js.br +0 -0
- package/dist/web/public/session-ui.186f9419.js.gz +0 -0
- package/dist/web/public/settings-ui.a191154e.js +106 -0
- package/dist/web/public/settings-ui.a191154e.js.br +0 -0
- package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.7238eae8.css +1 -0
- package/dist/web/public/styles.7238eae8.css.br +0 -0
- package/dist/web/public/styles.7238eae8.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.7157e0bd.js → tab-rail-resize.b5cdceaf.js} +1 -1
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.br +0 -0
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +10 -1
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.7e787829.js +4 -0
- package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
- package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/{xterm-predictive-echo.ae5e3d5d.js → xterm-predictive-echo.276b11d9.js} +1 -1
- package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.br +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js +5 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +9 -9
- package/dist/web/route-helpers.d.ts +57 -6
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +72 -4
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/agent-session-routes.d.ts +20 -0
- package/dist/web/routes/agent-session-routes.d.ts.map +1 -0
- package/dist/web/routes/agent-session-routes.js +160 -0
- package/dist/web/routes/agent-session-routes.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +203 -16
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/config-backup-routes.js +2 -2
- package/dist/web/routes/config-backup-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts +21 -7
- package/dist/web/routes/git-status-routes.d.ts.map +1 -1
- package/dist/web/routes/git-status-routes.js +78 -12
- package/dist/web/routes/git-status-routes.js.map +1 -1
- package/dist/web/routes/inbox-routes.d.ts +43 -0
- package/dist/web/routes/inbox-routes.d.ts.map +1 -0
- package/dist/web/routes/inbox-routes.js +148 -0
- package/dist/web/routes/inbox-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +7 -16
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +120 -74
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +107 -59
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts +5 -0
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +84 -47
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +56 -7
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +88 -1
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/sensitive-path.d.ts.map +1 -1
- package/dist/web/sensitive-path.js +2 -0
- package/dist/web/sensitive-path.js.map +1 -1
- package/dist/web/server.d.ts +7 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +144 -16
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +48 -4
- package/dist/web/session-wait-registry.d.ts.map +1 -1
- package/dist/web/session-wait-registry.js +144 -6
- package/dist/web/session-wait-registry.js.map +1 -1
- package/dist/web/settings-writer.d.ts +47 -0
- package/dist/web/settings-writer.d.ts.map +1 -0
- package/dist/web/settings-writer.js +110 -0
- package/dist/web/settings-writer.js.map +1 -0
- package/dist/web/sse-events.d.ts +4 -1
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +4 -1
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +30 -8
- package/skills/codeman/preamble.sh +3 -3
- package/skills/codeman/reference/endpoints.md +24 -3
- package/skills/codeman/reference/history.md +84 -0
- package/skills/codeman/reference/inspection-and-staging.md +152 -0
- package/skills/codeman/reference/messaging.md +1 -1
- package/skills/codeman/reference/verbs.md +65 -3
- package/dist/web/public/app.9314ee60.js +0 -43
- package/dist/web/public/app.9314ee60.js.br +0 -0
- package/dist/web/public/app.9314ee60.js.gz +0 -0
- package/dist/web/public/constants.4866ecc3.js.br +0 -0
- package/dist/web/public/constants.4866ecc3.js.gz +0 -0
- package/dist/web/public/i18n.22ca902b.js +0 -2
- package/dist/web/public/i18n.22ca902b.js.br +0 -0
- package/dist/web/public/i18n.22ca902b.js.gz +0 -0
- package/dist/web/public/mobile-handlers.44adaa07.js.br +0 -0
- package/dist/web/public/mobile-handlers.44adaa07.js.gz +0 -0
- package/dist/web/public/mobile.12fb18de.css +0 -1
- package/dist/web/public/mobile.12fb18de.css.br +0 -0
- package/dist/web/public/mobile.12fb18de.css.gz +0 -0
- package/dist/web/public/panels-ui.cbb24ba1.js.br +0 -0
- package/dist/web/public/panels-ui.cbb24ba1.js.gz +0 -0
- package/dist/web/public/session-ui.9c1f009b.js +0 -104
- package/dist/web/public/session-ui.9c1f009b.js.br +0 -0
- package/dist/web/public/session-ui.9c1f009b.js.gz +0 -0
- package/dist/web/public/settings-ui.fea9ddae.js +0 -106
- package/dist/web/public/settings-ui.fea9ddae.js.br +0 -0
- package/dist/web/public/settings-ui.fea9ddae.js.gz +0 -0
- package/dist/web/public/styles.e096fcd6.css +0 -1
- package/dist/web/public/styles.e096fcd6.css.br +0 -0
- package/dist/web/public/styles.e096fcd6.css.gz +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-ui.68dbd352.js +0 -2
- package/dist/web/public/terminal-ui.68dbd352.js.br +0 -0
- package/dist/web/public/terminal-ui.68dbd352.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.br +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +0 -4
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
- 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;
|
|
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:
|
|
310
|
-
//
|
|
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
|
-
|
|
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
|
|
412
|
-
* `mustChangePassword` lockbox.
|
|
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
|
-
//
|
|
507
|
-
//
|
|
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 >=
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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.
|