@bitseek/hermes-webui 0.1.0-beta.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 (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,592 @@
1
+ """
2
+ Hermes Web UI -- optional authentication.
3
+ Off by default. Enable by setting HERMES_WEBUI_PASSWORD, configuring a
4
+ password in Settings, or registering passkeys and then going passwordless.
5
+ """
6
+ import hashlib
7
+ import hmac
8
+ import http.cookies
9
+ import json
10
+ import logging
11
+ import os
12
+ import secrets
13
+ import tempfile
14
+ import threading
15
+ import time
16
+
17
+ from api.config import STATE_DIR, load_settings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # Default session TTL — 30 days. Kept as a module-level constant for backwards
23
+ # compatibility with downstream code and regression tests that import it.
24
+ # At runtime, prefer ``_resolve_session_ttl()`` which honours the env var and
25
+ # settings.json overrides; this constant is the floor / fallback.
26
+ SESSION_TTL = 86400 * 30 # 30 days
27
+
28
+
29
+ def _resolve_session_ttl() -> int:
30
+ """Resolve session TTL from env > settings > default.
31
+
32
+ Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
33
+ first, then settings.json, falling back to ``SESSION_TTL`` (30 days).
34
+ Clamped to [60s, 1 year] to prevent runaway cookies or self-lockout.
35
+ """
36
+ env_v = os.getenv('HERMES_WEBUI_SESSION_TTL', '').strip()
37
+ if env_v.isdigit():
38
+ val = int(env_v)
39
+ if 60 <= val <= 86400 * 365:
40
+ return val
41
+ s = load_settings()
42
+ v = s.get('session_ttl_seconds')
43
+ if isinstance(v, int) and 60 <= v <= 86400 * 365:
44
+ return v
45
+ return SESSION_TTL
46
+
47
+
48
+ # ── Public paths (no auth required) ─────────────────────────────────────────
49
+ PUBLIC_PATHS = frozenset({
50
+ '/login', '/health', '/favicon.ico', '/sw.js',
51
+ '/api/auth/login', '/api/auth/status',
52
+ '/api/auth/passkey/options', '/api/auth/passkey/login',
53
+ '/manifest.json', '/manifest.webmanifest',
54
+ '/session/manifest.json', '/session/manifest.webmanifest',
55
+ })
56
+
57
+ COOKIE_NAME = 'hermes_session'
58
+ CSRF_HEADER_NAME = 'X-Hermes-CSRF-Token'
59
+
60
+ _SESSIONS_FILE = STATE_DIR / '.sessions.json'
61
+
62
+
63
+ def _load_sessions() -> dict[str, float]:
64
+ """Load persisted sessions from STATE_DIR, pruning expired entries.
65
+
66
+ Returns an empty dict on any read or parse error so startup is never
67
+ blocked by a corrupt or missing sessions file.
68
+ """
69
+ try:
70
+ if _SESSIONS_FILE.exists():
71
+ data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
72
+ if not isinstance(data, dict):
73
+ raise ValueError('malformed sessions file — expected dict')
74
+ now = time.time()
75
+ return {t: exp for t, exp in data.items()
76
+ if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
77
+ except Exception as e:
78
+ logger.debug("Failed to load sessions file, starting fresh: %s", e)
79
+ return {}
80
+
81
+
82
+ def _save_sessions(sessions: dict[str, float]) -> None:
83
+ """Atomically persist sessions to STATE_DIR/.sessions.json (0600).
84
+
85
+ Uses a temp file + os.replace() so a crash mid-write never leaves a
86
+ truncated file. Mirrors the same pattern as .signing_key persistence.
87
+ """
88
+ try:
89
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
90
+ fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
91
+ try:
92
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
93
+ json.dump(sessions, f)
94
+ os.chmod(tmp, 0o600)
95
+ os.replace(tmp, _SESSIONS_FILE)
96
+ except Exception:
97
+ try:
98
+ os.unlink(tmp)
99
+ except OSError:
100
+ pass
101
+ raise
102
+ except Exception as e:
103
+ logger.debug("Failed to persist sessions: %s", e)
104
+
105
+
106
+ # Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
107
+ _sessions = _load_sessions()
108
+ _SESSIONS_LOCK = threading.Lock()
109
+
110
+ # ── Login rate limiter ──────────────────────────────────────────────────────
111
+ _LOGIN_ATTEMPTS_FILE = STATE_DIR / '.login_attempts.json'
112
+ _LOGIN_MAX_ATTEMPTS = 5
113
+ _LOGIN_WINDOW = 60 # seconds
114
+
115
+
116
+ def _load_login_attempts() -> dict[str, list[float]]:
117
+ """Load persisted login attempts from STATE_DIR, pruning expired entries."""
118
+ try:
119
+ if _LOGIN_ATTEMPTS_FILE.exists():
120
+ data = json.loads(_LOGIN_ATTEMPTS_FILE.read_text(encoding='utf-8'))
121
+ if not isinstance(data, dict):
122
+ raise ValueError('malformed login-attempts file — expected dict')
123
+ now = time.time()
124
+ attempts: dict[str, list[float]] = {}
125
+ for ip, raw_times in data.items():
126
+ if not isinstance(ip, str) or not isinstance(raw_times, list):
127
+ continue
128
+ fresh = [
129
+ float(t)
130
+ for t in raw_times
131
+ if isinstance(t, (int, float)) and now - float(t) < _LOGIN_WINDOW
132
+ ]
133
+ if fresh:
134
+ attempts[ip] = fresh
135
+ return attempts
136
+ except Exception as e:
137
+ logger.debug("Failed to load login attempts file, starting fresh: %s", e)
138
+ return {}
139
+
140
+
141
+ def _save_login_attempts(attempts: dict[str, list[float]]) -> None:
142
+ """Atomically persist login attempts to STATE_DIR/.login_attempts.json (0600)."""
143
+ try:
144
+ _LOGIN_ATTEMPTS_FILE.parent.mkdir(parents=True, exist_ok=True)
145
+ fd, tmp = tempfile.mkstemp(dir=_LOGIN_ATTEMPTS_FILE.parent, suffix='.login_attempts.tmp')
146
+ try:
147
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
148
+ json.dump(attempts, f)
149
+ os.chmod(tmp, 0o600)
150
+ os.replace(tmp, _LOGIN_ATTEMPTS_FILE)
151
+ except Exception:
152
+ try:
153
+ os.unlink(tmp)
154
+ except OSError:
155
+ pass
156
+ raise
157
+ except Exception as e:
158
+ logger.debug("Failed to persist login attempts: %s", e)
159
+
160
+
161
+ _login_attempts = _load_login_attempts() # ip -> [timestamp, ...]
162
+ _LOGIN_ATTEMPTS_LOCK = threading.Lock()
163
+
164
+
165
+ def _check_login_rate(ip: str) -> bool:
166
+ """Return True if the IP is allowed to attempt login (thread-safe)."""
167
+ with _LOGIN_ATTEMPTS_LOCK:
168
+ now = time.time()
169
+ attempts = _login_attempts.get(ip, [])
170
+ # Prune old attempts
171
+ attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
172
+ if attempts:
173
+ _login_attempts[ip] = attempts
174
+ else:
175
+ _login_attempts.pop(ip, None)
176
+ _save_login_attempts(_login_attempts)
177
+ return len(attempts) < _LOGIN_MAX_ATTEMPTS
178
+
179
+
180
+ def _record_login_attempt(ip: str) -> None:
181
+ """Record a login attempt for rate limiting (thread-safe)."""
182
+ with _LOGIN_ATTEMPTS_LOCK:
183
+ now = time.time()
184
+ attempts = _login_attempts.get(ip, [])
185
+ attempts.append(now)
186
+ _login_attempts[ip] = attempts
187
+ _save_login_attempts(_login_attempts)
188
+
189
+
190
+ def _clear_login_attempts(ip: str) -> None:
191
+ """Clear failed login attempts after a successful login (thread-safe)."""
192
+ with _LOGIN_ATTEMPTS_LOCK:
193
+ if ip in _login_attempts:
194
+ _login_attempts.pop(ip, None)
195
+ _save_login_attempts(_login_attempts)
196
+
197
+
198
+ def _load_key(filename: str) -> bytes:
199
+ """Load a 32-byte key from STATE_DIR, generating and persisting one if missing."""
200
+ key_file = STATE_DIR / filename
201
+ try:
202
+ if key_file.exists():
203
+ raw = key_file.read_bytes()
204
+ if len(raw) >= 32:
205
+ return raw[:32]
206
+ except OSError:
207
+ logger.debug("Failed to read key %s", filename)
208
+ key = secrets.token_bytes(32)
209
+ try:
210
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
211
+ key_file.write_bytes(key)
212
+ key_file.chmod(0o600)
213
+ except OSError:
214
+ logger.debug("Failed to persist key %s", filename)
215
+ return key
216
+
217
+
218
+ _PBKDF2_KEY_CACHE: bytes | None = None
219
+ _SIGNING_KEY_CACHE: bytes | None = None
220
+
221
+
222
+ def _pbkdf2_key() -> bytes:
223
+ global _PBKDF2_KEY_CACHE
224
+ if _PBKDF2_KEY_CACHE is None:
225
+ _PBKDF2_KEY_CACHE = _load_key('.pbkdf2_key')
226
+ return _PBKDF2_KEY_CACHE
227
+
228
+
229
+ def _signing_key() -> bytes:
230
+ global _SIGNING_KEY_CACHE
231
+ if _SIGNING_KEY_CACHE is None:
232
+ _SIGNING_KEY_CACHE = _load_key('.signing_key')
233
+ return _SIGNING_KEY_CACHE
234
+
235
+
236
+ def _hash_password(password, *, salt: bytes | None = None) -> str:
237
+ """PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
238
+ Salt is the persisted PBKDF2 key, which is secret and unique per
239
+ installation. This keeps the stored hash format a plain hex string
240
+ (no format change to settings.json) while replacing the predictable
241
+ STATE_DIR-derived salt from the original implementation.
242
+
243
+ The *salt* parameter exists solely to support transparent migration
244
+ of password hashes that were computed with a different key (e.g. the
245
+ old `.signing_key`). Normal callers should never pass it.
246
+ """
247
+ if salt is None:
248
+ salt = _pbkdf2_key()
249
+ dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
250
+ return dk.hex()
251
+
252
+
253
+ _AUTH_HASH_LOCK = threading.Lock()
254
+ _AUTH_HASH_COMPUTED: bool = False
255
+ _AUTH_HASH_CACHE: str | None = None
256
+
257
+
258
+ def _invalidate_password_hash_cache() -> None:
259
+ """Invalidate the in-process password hash cache so the next call to
260
+ get_password_hash() re-reads from settings.json or the env var."""
261
+ global _AUTH_HASH_COMPUTED, _AUTH_HASH_CACHE
262
+ with _AUTH_HASH_LOCK:
263
+ _AUTH_HASH_COMPUTED = False
264
+ _AUTH_HASH_CACHE = None
265
+
266
+
267
+ def get_password_hash() -> str | None:
268
+ """Return the active password hash, or None if auth is disabled.
269
+ Priority: env var > settings.json.
270
+
271
+ The hash is computed once and cached for the lifetime of the process.
272
+ PBKDF2-600k takes ~1 s and is called on nearly every HTTP request via
273
+ check_auth → is_auth_enabled, so caching avoids wasting a full second
274
+ of CPU per request after the first one.
275
+
276
+ Thread-safe: double-checked locking ensures that under a burst of
277
+ concurrent requests only one thread computes PBKDF2, while the fast
278
+ path (after initialisation) requires zero locks.
279
+ """
280
+ global _AUTH_HASH_COMPUTED, _AUTH_HASH_CACHE
281
+
282
+ # Fast path — no lock needed once cache is populated.
283
+ if _AUTH_HASH_COMPUTED:
284
+ return _AUTH_HASH_CACHE
285
+
286
+ with _AUTH_HASH_LOCK:
287
+ # Re-check inside lock — another thread may have populated while
288
+ # we were waiting to acquire.
289
+ if _AUTH_HASH_COMPUTED:
290
+ return _AUTH_HASH_CACHE
291
+
292
+ env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
293
+ if env_pw:
294
+ result = _hash_password(env_pw)
295
+ else:
296
+ result = load_settings().get('password_hash') or None
297
+
298
+ _AUTH_HASH_CACHE = result
299
+ _AUTH_HASH_COMPUTED = True
300
+ return result
301
+
302
+
303
+ def is_password_auth_enabled() -> bool:
304
+ """True if a password is configured (env var or settings)."""
305
+ return get_password_hash() is not None
306
+
307
+
308
+ def _passkey_feature_flag_enabled() -> bool:
309
+ """Return True if the passkey/WebAuthn surface is enabled for this deployment.
310
+
311
+ Passkey support is opt-in default-off behind a feature flag so deployments
312
+ that don't want the WebAuthn surface (or whose RP-ID setup isn't ready for
313
+ non-localhost hosts) can disable it entirely with no UI surface, no
314
+ endpoints, no credential storage. To enable:
315
+
316
+ - Set ``HERMES_WEBUI_PASSKEY=1`` in the environment, OR
317
+ - Set ``webui_passkey_enabled: true`` in the per-profile config.yaml
318
+
319
+ With the flag off, ``are_passkeys_enabled()`` always returns False even if
320
+ credentials were registered in the past, and ``/login`` shows password-only.
321
+ """
322
+ env_value = os.getenv("HERMES_WEBUI_PASSKEY", "")
323
+ if env_value:
324
+ return env_value.strip().lower() in {"1", "true", "yes", "on"}
325
+ try:
326
+ from api.config import get_config
327
+
328
+ cfg = get_config()
329
+ if isinstance(cfg, dict):
330
+ raw = cfg.get("webui_passkey_enabled")
331
+ if isinstance(raw, bool):
332
+ return raw
333
+ if isinstance(raw, str):
334
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
335
+ except Exception:
336
+ pass
337
+ return False
338
+
339
+
340
+ def are_passkeys_enabled() -> bool:
341
+ """True if the passkey feature flag is on AND at least one local passkey credential is registered."""
342
+ if not _passkey_feature_flag_enabled():
343
+ return False
344
+ try:
345
+ from api.passkeys import passkeys_available
346
+
347
+ return passkeys_available()
348
+ except Exception as exc:
349
+ logger.debug("Failed to inspect passkey availability: %s", exc)
350
+ return False
351
+
352
+
353
+ def is_auth_enabled() -> bool:
354
+ """True if password auth or passkey-only auth is configured."""
355
+ return is_password_auth_enabled() or are_passkeys_enabled()
356
+
357
+
358
+ def verify_password(plain: str) -> bool:
359
+ """Verify a plaintext password against the stored hash.
360
+
361
+ Supports transparent migration of password hashes that were computed
362
+ with the old `.signing_key` salt. When the two keys differ and the
363
+ legacy-salted hash matches, the password is transparently re-hashed
364
+ with the current `.pbkdf2_key` and persisted to settings.json.
365
+ """
366
+ expected = get_password_hash()
367
+ if not expected:
368
+ return False
369
+ # Fast path: current PBKDF2 key
370
+ if hmac.compare_digest(_hash_password(plain), expected):
371
+ return True
372
+ # Migration: some hashes were computed with `.signing_key` before the
373
+ # PBKDF2 key was separated. Try the legacy salt; if it matches,
374
+ # transparently upgrade so the next login uses the fast path.
375
+ legacy_salt = _signing_key()
376
+ current_salt = _pbkdf2_key()
377
+ if legacy_salt != current_salt:
378
+ if hmac.compare_digest(_hash_password(plain, salt=legacy_salt), expected):
379
+ from api.config import save_settings
380
+
381
+ save_settings({'_set_password': plain})
382
+ # Password re-hashed and persisted to disk using the current salt.
383
+ # Cache invalidation is handled by fix 2/3 (#2192) which adds the
384
+ # _invalidate_password_hash_cache() call inside save_settings().
385
+ return True
386
+ return False
387
+
388
+
389
+ def create_session() -> str:
390
+ """Create a new auth session. Returns signed cookie value."""
391
+ token = secrets.token_hex(32)
392
+ with _SESSIONS_LOCK:
393
+ _sessions[token] = time.time() + _resolve_session_ttl()
394
+ _save_sessions(_sessions)
395
+ sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()
396
+ return f"{token}.{sig}"
397
+
398
+
399
+ def _prune_expired_sessions():
400
+ """Remove all expired session entries to prevent unbounded memory growth."""
401
+ now = time.time()
402
+ with _SESSIONS_LOCK:
403
+ expired = [t for t, exp in _sessions.items() if now > exp]
404
+ if expired:
405
+ for token in expired:
406
+ _sessions.pop(token, None)
407
+ _save_sessions(_sessions)
408
+
409
+
410
+ def verify_session(cookie_value: str) -> bool:
411
+ """Verify a signed session cookie. Returns True if valid and not expired."""
412
+ if not cookie_value or '.' not in cookie_value:
413
+ return False
414
+ _prune_expired_sessions() # lazy cleanup on every verification attempt
415
+ token, sig = cookie_value.rsplit('.', 1)
416
+ full_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()
417
+ # Accept both new (64-char) and legacy (32-char truncated) signatures so
418
+ # existing sessions survive the upgrade without a forced global logout.
419
+ # The legacy branch can be removed once session TTLs have expired (~30 days).
420
+ valid = hmac.compare_digest(sig, full_sig) or (
421
+ len(sig) == 32 and hmac.compare_digest(sig, full_sig[:32])
422
+ )
423
+ if not valid:
424
+ return False
425
+ with _SESSIONS_LOCK:
426
+ expiry = _sessions.get(token)
427
+ if not expiry or time.time() > expiry:
428
+ _sessions.pop(token, None)
429
+ _save_sessions(_sessions)
430
+ return False
431
+ return True
432
+
433
+
434
+ def _session_token_from_cookie_value(cookie_value: str) -> str | None:
435
+ """Return the raw server-side session token from a signed cookie value."""
436
+ if not cookie_value or '.' not in cookie_value:
437
+ return None
438
+ token, _sig = cookie_value.rsplit('.', 1)
439
+ return token or None
440
+
441
+
442
+ def csrf_token_for_session(cookie_value: str) -> str | None:
443
+ """Return the CSRF token bound to an authenticated WebUI session.
444
+
445
+ The browser can read this token from the authenticated shell and echoes it
446
+ in ``X-Hermes-CSRF-Token`` on unsafe API requests. The token is derived
447
+ from the HttpOnly session cookie's server-side token, so it automatically
448
+ rotates on login and is invalidated when the auth session expires or logs
449
+ out. Callers must still verify the auth session before trusting it.
450
+ """
451
+ token = _session_token_from_cookie_value(cookie_value)
452
+ if not token:
453
+ return None
454
+ return hmac.new(_signing_key(), f"csrf:{token}".encode(), hashlib.sha256).hexdigest()
455
+
456
+
457
+ def verify_csrf_token(cookie_value: str, csrf_token: str) -> bool:
458
+ """Verify a submitted CSRF token against the authenticated session."""
459
+ if not cookie_value or not csrf_token or not verify_session(cookie_value):
460
+ return False
461
+ expected = csrf_token_for_session(cookie_value)
462
+ return bool(expected and hmac.compare_digest(str(csrf_token), expected))
463
+
464
+
465
+ def invalidate_session(cookie_value) -> None:
466
+ """Remove a session token."""
467
+ if cookie_value and '.' in cookie_value:
468
+ token = cookie_value.rsplit('.', 1)[0]
469
+ with _SESSIONS_LOCK:
470
+ if token in _sessions:
471
+ _sessions.pop(token, None)
472
+ _save_sessions(_sessions)
473
+
474
+
475
+ def parse_cookie(handler) -> str | None:
476
+ """Extract the auth cookie from the request headers."""
477
+ cookie_header = handler.headers.get('Cookie', '')
478
+ if not cookie_header:
479
+ return None
480
+ cookie = http.cookies.SimpleCookie()
481
+ try:
482
+ cookie.load(cookie_header)
483
+ except http.cookies.CookieError:
484
+ return None
485
+ morsel = cookie.get(COOKIE_NAME)
486
+ return morsel.value if morsel else None
487
+
488
+
489
+ def check_auth(handler, parsed) -> bool:
490
+ """Check if request is authorized. Returns True if OK.
491
+ If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
492
+ if not is_auth_enabled():
493
+ return True
494
+ # Public paths don't require auth
495
+ if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/') or parsed.path.startswith('/session/static/'):
496
+ return True
497
+ # Check session cookie
498
+ cookie_val = parse_cookie(handler)
499
+ if cookie_val and verify_session(cookie_val):
500
+ return True
501
+ # Not authorized
502
+ if parsed.path.startswith('/api/'):
503
+ body = b'{"error":"Authentication required"}'
504
+ handler.send_response(401)
505
+ handler.send_header('Content-Type', 'application/json')
506
+ handler.send_header('Content-Length', str(len(body)))
507
+ handler.end_headers()
508
+ handler.wfile.write(body)
509
+ else:
510
+ handler.send_response(302)
511
+ # Pass the original path as ?next= so login.js redirects back after auth.
512
+ # SECURITY/CORRECTNESS: the inner `?` and `&` MUST be percent-encoded
513
+ # when stuffed into the outer `?next=` parameter, otherwise:
514
+ # (a) multi-param query strings get truncated at the first inner `&`
515
+ # (e.g. `/api/sessions?limit=50&offset=0` would round-trip as
516
+ # just `/api/sessions?limit=50` after the browser parses the
517
+ # outer URL — `offset=0` becomes a separate top-level query
518
+ # parameter that the login page ignores).
519
+ # (b) attacker-controlled paths could inject a second `next=`
520
+ # parameter; per RFC 3986 the duplicate behaviour is undefined
521
+ # and parsers diverge (Python's parse_qs returns last-match,
522
+ # URLSearchParams returns first-match), opening a query-pollution
523
+ # footgun even though _safeNextPath() rejects most malicious
524
+ # shapes downstream.
525
+ # Encoding the entire `path?query` blob with quote(safe='/') turns
526
+ # `?` → `%3F` and `&` → `%26`, so the outer parameter holds exactly
527
+ # one path-with-query string and `searchParams.get('next')` returns
528
+ # the full original URL (the browser auto-decodes once).
529
+ # (Opus pre-release advisor finding for v0.50.258.)
530
+ import urllib.parse as _urlparse
531
+ _path_with_query = parsed.path or '/'
532
+ if parsed.query:
533
+ _path_with_query += '?' + parsed.query
534
+ # safe='/' keeps path separators readable; everything else (including
535
+ # `?`, `&`, `=`) gets percent-encoded.
536
+ _next = _urlparse.quote(_path_with_query, safe='/')
537
+ handler.send_header('Location', 'login?next=' + _next)
538
+ handler.send_header('Content-Length', '0')
539
+ handler.end_headers()
540
+ return False
541
+
542
+
543
+ def _is_secure_context(handler=None) -> bool:
544
+ """Return True if cookies should carry the Secure flag.
545
+
546
+ Behaviour is overridable via HERMES_WEBUI_SECURE env var for
547
+ reverse-proxy setups where TLS terminates at a frontend proxy
548
+ (nginx, Cloudflare, etc.) and Python only sees plain HTTP.
549
+ 1/true/yes → force Secure on; 0/false/no → force Secure off.
550
+ When unset, fall back to heuristics: direct TLS socket (getpeercert)
551
+ or X-Forwarded-Proto header from the request.
552
+
553
+ .. warning::
554
+ The ``X-Forwarded-Proto`` header is only trustworthy when a
555
+ reverse proxy (nginx, Cloudflare, etc.) is deployed in front
556
+ of the application. Without a proxy, any client can forge the
557
+ header and cause the Secure flag to be set on plain HTTP.
558
+ """
559
+ env = os.getenv('HERMES_WEBUI_SECURE', '').strip().lower()
560
+ if env in ('1', 'true', 'yes'):
561
+ return True
562
+ if env in ('0', 'false', 'no'):
563
+ return False
564
+ if handler is not None:
565
+ if getattr(handler.request, 'getpeercert', None) is not None:
566
+ return True
567
+ if handler.headers.get('X-Forwarded-Proto', '') == 'https':
568
+ return True
569
+ return False
570
+
571
+
572
+ def set_auth_cookie(handler, cookie_value) -> None:
573
+ """Set the auth cookie on the response."""
574
+ cookie = http.cookies.SimpleCookie()
575
+ cookie[COOKIE_NAME] = cookie_value
576
+ cookie[COOKIE_NAME]['httponly'] = True
577
+ cookie[COOKIE_NAME]['samesite'] = 'Lax'
578
+ cookie[COOKIE_NAME]['path'] = '/'
579
+ cookie[COOKIE_NAME]['max-age'] = str(_resolve_session_ttl())
580
+ if _is_secure_context(handler):
581
+ cookie[COOKIE_NAME]['secure'] = True
582
+ handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
583
+
584
+
585
+ def clear_auth_cookie(handler) -> None:
586
+ """Clear the auth cookie on the response."""
587
+ cookie = http.cookies.SimpleCookie()
588
+ cookie[COOKIE_NAME] = ''
589
+ cookie[COOKIE_NAME]['httponly'] = True
590
+ cookie[COOKIE_NAME]['path'] = '/'
591
+ cookie[COOKIE_NAME]['max-age'] = '0'
592
+ handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
@@ -0,0 +1,87 @@
1
+ """Background and ephemeral task tracking for /background and /btw commands."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ import time
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _lock = threading.Lock()
12
+
13
+ # parent_session_id -> list of task dicts
14
+ _BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
15
+
16
+ # btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
17
+ _BTW_TRACKING: dict[str, dict[str, Any]] = {}
18
+
19
+
20
+ def track_background(parent_sid: str, bg_sid: str, stream_id: str,
21
+ task_id: str, prompt: str) -> None:
22
+ with _lock:
23
+ _BACKGROUND_TASKS.setdefault(parent_sid, []).append({
24
+ "task_id": task_id,
25
+ "bg_session_id": bg_sid,
26
+ "stream_id": stream_id,
27
+ "prompt": prompt,
28
+ "status": "running",
29
+ "started_at": time.time(),
30
+ "answer": None,
31
+ "completed_at": None,
32
+ })
33
+
34
+
35
+ def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
36
+ question: str) -> None:
37
+ with _lock:
38
+ _BTW_TRACKING[parent_sid] = {
39
+ "ephemeral_session_id": ephemeral_sid,
40
+ "stream_id": stream_id,
41
+ "question": question,
42
+ }
43
+
44
+
45
+ def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
46
+ with _lock:
47
+ for t in _BACKGROUND_TASKS.get(parent_sid, []):
48
+ if t["task_id"] == task_id and t["status"] == "running":
49
+ t["status"] = "done"
50
+ t["answer"] = answer
51
+ t["completed_at"] = time.time()
52
+ break
53
+
54
+
55
+ def get_results(parent_sid: str) -> list[dict[str, Any]]:
56
+ """Return completed background task results and remove only the done ones
57
+ from tracking. Tasks still in ``status="running"`` MUST stay in the list
58
+ so that ``complete_background()`` can still find them when the worker
59
+ thread finishes — otherwise the first poll during a long-running task
60
+ silently drops it and the result is lost forever.
61
+ """
62
+ with _lock:
63
+ tasks = _BACKGROUND_TASKS.get(parent_sid, [])
64
+ done = [t for t in tasks if t["status"] == "done"]
65
+ still_running = [t for t in tasks if t["status"] != "done"]
66
+ if still_running:
67
+ _BACKGROUND_TASKS[parent_sid] = still_running
68
+ else:
69
+ _BACKGROUND_TASKS.pop(parent_sid, None)
70
+ return [{
71
+ "task_id": t["task_id"],
72
+ "prompt": t["prompt"],
73
+ "answer": t["answer"],
74
+ "completed_at": t["completed_at"],
75
+ } for t in done]
76
+
77
+
78
+ def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
79
+ """Return all background tasks (running and done) for a parent session."""
80
+ with _lock:
81
+ return list(_BACKGROUND_TASKS.get(parent_sid, []))
82
+
83
+
84
+ def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
85
+ """Remove and return btw tracking for a parent session."""
86
+ with _lock:
87
+ return _BTW_TRACKING.pop(parent_sid, None)