@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,624 @@
1
+ """
2
+ Hermes Web UI -- Main server entry point.
3
+ Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
4
+ All business logic lives in api/*.
5
+ """
6
+ import logging
7
+ import os
8
+ import re
9
+ import socket
10
+ import sys
11
+ import threading
12
+ import time
13
+ import traceback
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+
16
+ # ── Test-mode network isolation ─────────────────────────────────────────────
17
+ # When `HERMES_WEBUI_TEST_NETWORK_BLOCK=1` is set in the environment, refuse
18
+ # outbound socket connections to anything that is not loopback / RFC1918 /
19
+ # link-local / reserved-TLD. This catches accidental real outbound (forgotten
20
+ # mocks, leaked credentials triggering SDK init, new code paths bypassing an
21
+ # existing mock) so the test suite stays hermetic and fast.
22
+ #
23
+ # tests/conftest.py sets this env var on every test_server subprocess so the
24
+ # server.py-side network isolation matches the pytest-process-side isolation
25
+ # already installed there.
26
+ #
27
+ # A test that legitimately needs real outbound spawns the server with the env
28
+ # var unset (no current callers — every test_server-using test should be
29
+ # mockable).
30
+ if os.environ.get("HERMES_WEBUI_TEST_NETWORK_BLOCK", "").strip() in ("1", "true", "yes"):
31
+ _REAL_CREATE_CONN = socket.create_connection
32
+ _REAL_SOCK_CONNECT = socket.socket.connect
33
+
34
+ import re as _re
35
+
36
+ def _re_match_unique_local_ipv6(h):
37
+ """Match IPv6 fc00::/7 (canonical syntax). Tighter than startswith('fc')
38
+ so we don't mistakenly classify hostnames like 'food.example.com' as local."""
39
+ return bool(_re.match(r"^f[cd][0-9a-f]{0,2}:", h))
40
+
41
+ def _addr_is_local(host):
42
+ if not isinstance(host, str):
43
+ return False
44
+ h = host.strip().lower()
45
+ if not h:
46
+ return False
47
+ # IPv6 unique-local fc00::/7: require hex pair + colon to avoid
48
+ # matching hostnames like "food.example.com" or "fdsa.test".
49
+ if h in ("::1", "0:0:0:0:0:0:0:1") or h.startswith("fe80:") or _re_match_unique_local_ipv6(h):
50
+ return True
51
+ if h == "localhost" or h.endswith(".localhost"):
52
+ return True
53
+ if h.endswith(".local") or h.endswith(".test") or h.endswith(".invalid"):
54
+ return True
55
+ if h == "example.com" or h.endswith(".example.com"):
56
+ return True
57
+ if h == "example.net" or h.endswith(".example.net"):
58
+ return True
59
+ if h == "example.org" or h.endswith(".example.org"):
60
+ return True
61
+ if h.endswith(".example"):
62
+ return True
63
+ if h and h[0].isdigit() and h.count(".") == 3:
64
+ try:
65
+ o1, o2, o3, o4 = [int(p) for p in h.split(".")]
66
+ except ValueError:
67
+ return False
68
+ if o1 == 127:
69
+ return True
70
+ if o1 == 10:
71
+ return True
72
+ if o1 == 192 and o2 == 168:
73
+ return True
74
+ if o1 == 172 and 16 <= o2 <= 31:
75
+ return True
76
+ if o1 == 169 and o2 == 254:
77
+ return True
78
+ if o1 == 203 and o2 == 0 and o3 == 113:
79
+ return True
80
+ return False
81
+
82
+ def _blocked_create_connection(address, *a, **kw):
83
+ try:
84
+ host = address[0]
85
+ except (TypeError, IndexError):
86
+ host = ""
87
+ if _addr_is_local(host):
88
+ return _REAL_CREATE_CONN(address, *a, **kw)
89
+ raise OSError(
90
+ f"hermes test network isolation (server.py): outbound to {address!r} blocked"
91
+ )
92
+
93
+ def _blocked_socket_connect(self, address):
94
+ try:
95
+ host = address[0]
96
+ except (TypeError, IndexError):
97
+ host = ""
98
+ if _addr_is_local(host):
99
+ return _REAL_SOCK_CONNECT(self, address)
100
+ raise OSError(
101
+ f"hermes test network isolation (server.py): socket.connect to {address!r} blocked"
102
+ )
103
+
104
+ socket.create_connection = _blocked_create_connection
105
+ socket.socket.connect = _blocked_socket_connect
106
+
107
+
108
+ try:
109
+ import resource
110
+ except ImportError: # pragma: no cover - resource is Unix-only
111
+ resource = None
112
+ from urllib.parse import urlparse
113
+
114
+ logger = logging.getLogger(__name__)
115
+
116
+ _CSP_CONNECT_BASE = (
117
+ "'self' http://127.0.0.1:* http://localhost:* "
118
+ "ws://127.0.0.1:* ws://localhost:*"
119
+ )
120
+ _CSP_EXTRA_CONNECT_RE = re.compile(
121
+ r"^(?:https?|wss?)://(?:\*\.)?[A-Za-z0-9._~-]+(?::(?P<port>\d{1,5}|\*))?$"
122
+ )
123
+
124
+
125
+ def _valid_csp_extra_connect_source(source: str) -> bool:
126
+ match = _CSP_EXTRA_CONNECT_RE.fullmatch(source)
127
+ if not match:
128
+ return False
129
+ port = match.group("port")
130
+ if not port or port == "*":
131
+ return True
132
+ try:
133
+ return 1 <= int(port) <= 65535
134
+ except ValueError:
135
+ return False
136
+
137
+
138
+ def _csp_extra_connect_src() -> str:
139
+ raw = os.getenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", "").strip()
140
+ if not raw:
141
+ return ""
142
+ sources = raw.split()
143
+ if not sources or any(not _valid_csp_extra_connect_source(src) for src in sources):
144
+ logger.warning("Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA value")
145
+ return ""
146
+ return " " + " ".join(sources)
147
+
148
+
149
+ def _build_csp_report_only_policy() -> str:
150
+ connect_src = _CSP_CONNECT_BASE + _csp_extra_connect_src()
151
+ return (
152
+ "default-src 'self'; "
153
+ "base-uri 'self'; "
154
+ "object-src 'none'; "
155
+ "frame-ancestors 'self'; "
156
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
157
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
158
+ "img-src 'self' data: blob:; "
159
+ "font-src 'self' data:; "
160
+ "media-src 'self' data: blob:; "
161
+ f"connect-src {connect_src}; "
162
+ "report-uri /api/csp-report; report-to csp-endpoint"
163
+ )
164
+
165
+ from api.auth import check_auth
166
+ from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
167
+ from api.helpers import j, get_profile_cookie, _CLIENT_DISCONNECT_ERRORS
168
+ from api.profiles import set_request_profile, clear_request_profile
169
+ from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put
170
+ from api.startup import auto_install_agent_deps, fix_credential_permissions
171
+ from api.updates import WEBUI_VERSION
172
+
173
+
174
+ class QuietHTTPServer(ThreadingHTTPServer):
175
+ """Custom HTTP server that silently handles common network errors."""
176
+ daemon_threads = True
177
+ request_queue_size = 64
178
+
179
+ def __init__(self, *args, **kwargs):
180
+ server_address = args[0] if args else kwargs.get('server_address', None)
181
+ if server_address and ':' in server_address[0]:
182
+ self.address_family = socket.AF_INET6
183
+ super().__init__(*args, **kwargs)
184
+ self.accept_loop_requests_total = 0
185
+ self.accept_loop_last_request_at = 0.0
186
+
187
+ def _handle_request_noblock(self):
188
+ """Record accept-loop progress before dispatching a request handler.
189
+
190
+ A process can be alive and still stop accepting/dispatching requests.
191
+ Exposing this heartbeat on /health gives supervisors and watchdogs a
192
+ cheap signal that the accept loop is still moving.
193
+
194
+ Note: this method is called only from the single ``serve_forever()``
195
+ thread in CPython socketserver, so the un-locked ``+=`` increment is
196
+ safe — there is no other thread mutating these counters. The /health
197
+ readers may see a stale value momentarily but never an inconsistent
198
+ one (Python int reads are atomic). Per Opus advisor on stage-297.
199
+ """
200
+ self.accept_loop_requests_total += 1
201
+ self.accept_loop_last_request_at = time.time()
202
+ return super()._handle_request_noblock()
203
+
204
+ def handle_error(self, request, client_address):
205
+ """Override to suppress logging for common client disconnect errors."""
206
+ exc_type, exc_value, _ = sys.exc_info()
207
+
208
+ # Silently ignore common connection errors caused by client disconnects
209
+ if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, TimeoutError):
210
+ return
211
+
212
+ # Also handle socket errors that indicate client disconnect
213
+ if issubclass(exc_type, OSError):
214
+ # errno 54 is Connection reset by peer on macOS/BSD
215
+ # errno 104 is Connection reset by peer on Linux
216
+ if getattr(exc_value, 'errno', None) in (32, 54, 104, 110): # EPIPE, ECONNRESET, ETIMEDOUT
217
+ return
218
+
219
+ # For other errors, use default logging
220
+ super().handle_error(request, client_address)
221
+
222
+
223
+ class Handler(BaseHTTPRequestHandler):
224
+ # HTTP/1.1 enables keep-alive connection reuse — major latency win on
225
+ # high-RTT links where every saved TCP handshake is 2×RTT. Each response
226
+ # MUST declare framing (Content-Length, Transfer-Encoding: chunked, or
227
+ # Connection: close) so the client knows where the message ends. Helpers
228
+ # j()/t() emit Content-Length; SSE/streaming endpoints emit
229
+ # Connection: close because the body has no terminator. See PR notes.
230
+ protocol_version = "HTTP/1.1"
231
+ timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
232
+
233
+ def setup(self):
234
+ """Set socket options for each accepted connection."""
235
+ super().setup()
236
+ # TCP_NODELAY — universal, disables Nagle for HTTP latency
237
+ try:
238
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
239
+ except OSError:
240
+ pass
241
+ # SO_KEEPALIVE — universal master switch (must be set before timing params)
242
+ try:
243
+ self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
244
+ except OSError:
245
+ pass
246
+ # Per-platform timing parameters
247
+ if hasattr(socket, 'TCP_KEEPIDLE'): # Linux
248
+ try:
249
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
250
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
251
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
252
+ except OSError:
253
+ pass
254
+ elif hasattr(socket, 'TCP_KEEPALIVE'): # macOS
255
+ try:
256
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 10)
257
+ except OSError:
258
+ pass
259
+ _ver_suffix = WEBUI_VERSION.removeprefix('v')
260
+ server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
261
+ _CSP_REPORT_TO = '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}'
262
+
263
+ @classmethod
264
+ def csp_report_only_policy(cls) -> str:
265
+ return _build_csp_report_only_policy()
266
+
267
+ def end_headers(self) -> None:
268
+ self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy())
269
+ self.send_header("Report-To", self._CSP_REPORT_TO)
270
+ super().end_headers()
271
+
272
+ def log_message(self, fmt, *args): pass # suppress default Apache-style log
273
+
274
+ def log_request(self, code: str='-', size: str='-') -> None:
275
+ """Structured JSON logs for each request."""
276
+ import json as _json
277
+ duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
278
+ remote = '-'
279
+ try:
280
+ if getattr(self, 'client_address', None):
281
+ remote = str(self.client_address[0])
282
+ except Exception:
283
+ remote = '-'
284
+ forwarded_for = None
285
+ try:
286
+ forwarded_for = (self.headers.get('X-Forwarded-For') or '').split(',')[0].strip() or None
287
+ except Exception:
288
+ forwarded_for = None
289
+ record_data = {
290
+ 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
291
+ 'remote': remote,
292
+ 'method': getattr(self, 'command', None) or '-',
293
+ 'path': getattr(self, 'path', None) or '-',
294
+ 'status': int(code) if str(code).isdigit() else code,
295
+ 'ms': duration_ms,
296
+ }
297
+ if forwarded_for:
298
+ record_data['forwarded_for'] = forwarded_for
299
+ record = _json.dumps(record_data)
300
+ print(f'[webui] {record}', flush=True)
301
+
302
+ def do_GET(self) -> None:
303
+ self._req_t0 = time.time()
304
+ # Per-request profile context from cookie (issue #798)
305
+ cookie_profile = get_profile_cookie(self)
306
+ if cookie_profile:
307
+ set_request_profile(cookie_profile)
308
+ try:
309
+ parsed = urlparse(self.path)
310
+ if not check_auth(self, parsed): return
311
+ result = handle_get(self, parsed)
312
+ if result is False:
313
+ return j(self, {'error': 'not found'}, status=404)
314
+ except _CLIENT_DISCONNECT_ERRORS:
315
+ # The browser/client closed the socket while we were writing the
316
+ # response. This is expected for probes, tab closes, and SSE
317
+ # reconnect races; do not convert it into a misleading server 500.
318
+ return
319
+ except Exception:
320
+ print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
321
+ try:
322
+ j(self, {'error': 'Internal server error'}, status=500)
323
+ except _CLIENT_DISCONNECT_ERRORS:
324
+ # Client disconnected while we were sending the 500 — nothing to do.
325
+ pass
326
+ except Exception:
327
+ # Unexpected failure while sending the error response itself.
328
+ # Log it so we know something is wrong with our error handler.
329
+ traceback.print_exc()
330
+ finally:
331
+ clear_request_profile()
332
+
333
+ def _handle_write(self, route_func) -> None:
334
+ self._req_t0 = time.time()
335
+ # Per-request profile context from cookie (issue #798)
336
+ cookie_profile = get_profile_cookie(self)
337
+ if cookie_profile:
338
+ set_request_profile(cookie_profile)
339
+ try:
340
+ parsed = urlparse(self.path)
341
+ # Stage-346 Opus SHOULD-FIX defense-in-depth: scope the CSP-report
342
+ # auth carve-out to POST only. The endpoint is intentionally
343
+ # unauthenticated (browsers omit cookies on CSP reports), but the
344
+ # carve-out should not extend to PATCH/DELETE on that path even
345
+ # though they currently fail through CSRF/routing fallthrough.
346
+ _is_csp_report_post = (
347
+ parsed.path == "/api/csp-report" and self.command == "POST"
348
+ )
349
+ if not _is_csp_report_post and not check_auth(self, parsed): return
350
+ result = route_func(self, parsed)
351
+ if result is False:
352
+ return j(self, {'error': 'not found'}, status=404)
353
+ except _CLIENT_DISCONNECT_ERRORS:
354
+ # The browser/client closed the socket while we were writing the
355
+ # response. This is expected for probes, tab closes, and SSE
356
+ # reconnect races; do not convert it into a misleading server 500.
357
+ return
358
+ except Exception:
359
+ print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
360
+ try:
361
+ j(self, {'error': 'Internal server error'}, status=500)
362
+ except _CLIENT_DISCONNECT_ERRORS:
363
+ # Client disconnected while we were sending the 500 — nothing to do.
364
+ pass
365
+ except Exception:
366
+ # Unexpected failure while sending the error response itself.
367
+ # Log it so we know something is wrong with our error handler.
368
+ traceback.print_exc()
369
+ finally:
370
+ clear_request_profile()
371
+
372
+ def do_POST(self) -> None:
373
+ self._handle_write(handle_post)
374
+
375
+ def do_PUT(self) -> None:
376
+ self._handle_write(handle_put)
377
+
378
+ def do_PATCH(self) -> None:
379
+ self._handle_write(handle_patch)
380
+
381
+ def do_OPTIONS(self) -> None:
382
+ """Handle CORS preflight requests."""
383
+ self._req_t0 = time.time()
384
+ self.send_response(200)
385
+ self.send_header("Access-Control-Allow-Origin", "*")
386
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
387
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
388
+ self.end_headers()
389
+
390
+ def do_DELETE(self) -> None:
391
+ self._handle_write(handle_delete)
392
+
393
+
394
+ def _raise_fd_soft_limit(target: int = 4096) -> dict:
395
+ """Best-effort raise of RLIMIT_NOFILE for persistent WebUI hosts.
396
+
397
+ macOS launchd jobs often start with a 256 soft limit. If a future FD leak
398
+ regresses, that low ceiling turns a leak into a hard HTTP wedge quickly.
399
+ Raising the soft limit does not hide leaks; it buys enough headroom for
400
+ diagnostics and watchdog recovery.
401
+ """
402
+ if resource is None:
403
+ return {"status": "unsupported"}
404
+ try:
405
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
406
+ except Exception as exc:
407
+ return {"status": "error", "error": str(exc)}
408
+
409
+ # On Unix, RLIM_INFINITY is commonly a large int; keep the logic explicit
410
+ # so tests can use ordinary integers without depending on platform values.
411
+ desired = int(target)
412
+ if hard not in (-1, getattr(resource, "RLIM_INFINITY", object())):
413
+ desired = min(desired, int(hard))
414
+ if soft >= desired:
415
+ return {"status": "unchanged", "soft": soft, "hard": hard}
416
+ try:
417
+ resource.setrlimit(resource.RLIMIT_NOFILE, (desired, hard))
418
+ except Exception as exc:
419
+ return {"status": "error", "soft": soft, "hard": hard, "error": str(exc)}
420
+ return {"status": "raised", "soft": desired, "hard": hard, "previous_soft": soft}
421
+
422
+
423
+ _SHUTDOWN_AUDIT_LOGGED = False
424
+ _SHUTDOWN_LOG_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]+")
425
+
426
+
427
+ def _shutdown_log_value(value, *, default: str = "unknown", max_len: int = 160) -> str:
428
+ """Return a bounded single-line value safe for shutdown diagnostics."""
429
+ if value is None:
430
+ return default
431
+ try:
432
+ text = str(value)
433
+ except Exception:
434
+ return default
435
+ text = _SHUTDOWN_LOG_VALUE_RE.sub("?", text).strip()
436
+ if not text:
437
+ return default
438
+ if len(text) > max_len:
439
+ text = f"{text[:max_len]}…"
440
+ return text
441
+
442
+
443
+ def _log_shutdown_audit(reason: str = "serve_forever_exit") -> None:
444
+ """Log runtime context when the WebUI server is exiting."""
445
+ global _SHUTDOWN_AUDIT_LOGGED
446
+ if _SHUTDOWN_AUDIT_LOGGED:
447
+ return
448
+
449
+ active_sessions = []
450
+ try:
451
+ from api.models import LOCK, SESSIONS
452
+ with LOCK:
453
+ session_items = list(SESSIONS.items())
454
+ for sid, session in session_items:
455
+ stream_id = getattr(session, "active_stream_id", None)
456
+ if stream_id:
457
+ pending = bool(getattr(session, "pending_user_message", None))
458
+ active_sessions.append(
459
+ "sid=%s stream=%s pending=%s"
460
+ % (
461
+ _shutdown_log_value(sid),
462
+ _shutdown_log_value(stream_id),
463
+ pending,
464
+ )
465
+ )
466
+ except Exception:
467
+ logger.debug("Failed to collect active-session shutdown audit state", exc_info=True)
468
+
469
+ _SHUTDOWN_AUDIT_LOGGED = True
470
+ logger.info(
471
+ "[shutdown-audit] reason=%s pid=%s thread=%s(%s) active_sessions=[%s]",
472
+ _shutdown_log_value(reason),
473
+ os.getpid(),
474
+ _shutdown_log_value(threading.current_thread().name),
475
+ threading.current_thread().ident,
476
+ "; ".join(active_sessions) if active_sessions else "none",
477
+ )
478
+
479
+
480
+ def main() -> None:
481
+ from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
482
+
483
+ # ── Remote debug attach support ───────────────────────────────────────
484
+ # Set HERMES_WEBUI_DEBUG_PORT=5678 to enable remote debugging.
485
+ # VSCode: use "🔗 附加到已运行进程 (5678)" configuration.
486
+ _debug_port = os.environ.get("HERMES_WEBUI_DEBUG_PORT", "").strip()
487
+ if _debug_port:
488
+ try:
489
+ import debugpy
490
+ debugpy.listen(("127.0.0.1", int(_debug_port)))
491
+ print(f"[debug] Waiting for debugger to attach on port {_debug_port}...", flush=True)
492
+ debugpy.wait_for_client()
493
+ print(f"[debug] Debugger attached!", flush=True)
494
+ except ImportError:
495
+ print("[debug] WARNING: debugpy not installed, skipping remote debug", flush=True)
496
+ except Exception as e:
497
+ print(f"[debug] WARNING: debugpy attach failed: {e}", flush=True)
498
+
499
+ print_startup_config()
500
+
501
+ fd_limit = _raise_fd_soft_limit()
502
+ if fd_limit.get("status") == "raised":
503
+ print(
504
+ f"[ok] Raised file descriptor soft limit "
505
+ f"{fd_limit.get('previous_soft')} -> {fd_limit.get('soft')}",
506
+ flush=True,
507
+ )
508
+ elif fd_limit.get("status") == "error":
509
+ print(f"[!!] WARNING: Could not raise file descriptor limit: {fd_limit.get('error')}", flush=True)
510
+
511
+ # Fix sensitive file permissions before doing anything else
512
+ fix_credential_permissions()
513
+
514
+ # ── #1558 startup self-heal ─────────────────────────────────────────
515
+ # If a previous process wrote a session JSON with fewer messages than
516
+ # its .bak (the data-loss shape #1558 produced), restore from the .bak.
517
+ # Safe to run unconditionally — a clean install is a no-op.
518
+ try:
519
+ from api.models import _active_state_db_path
520
+ from api.session_recovery import recover_all_sessions_on_startup
521
+ result = recover_all_sessions_on_startup(
522
+ SESSION_DIR,
523
+ rebuild_index=True,
524
+ state_db_path=_active_state_db_path(),
525
+ )
526
+ if result.get("restored"):
527
+ print(f"[recovery] Restored {result['restored']}/{result['scanned']} sessions from .bak (see #1558).", flush=True)
528
+ except Exception as exc:
529
+ # Recovery is best-effort; never block server startup.
530
+ print(f"[recovery] startup recovery failed: {exc}", flush=True)
531
+
532
+ within_container = False
533
+ # Check for the "/.within_container" file to determine if we're running inside a container; this file is created in the Dockerfile
534
+ try:
535
+ with open('/.within_container', 'r') as f:
536
+ within_container = True
537
+ except FileNotFoundError:
538
+ pass
539
+
540
+ if within_container:
541
+ print('[ok] Running within container.', flush=True)
542
+
543
+ # Security: warn if binding non-loopback without authentication
544
+ from api.auth import is_auth_enabled
545
+ if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled():
546
+ print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True)
547
+ print(f' Anyone on the network can access your filesystem and agent.', flush=True)
548
+ print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True)
549
+ print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True)
550
+ if within_container:
551
+ print(f' Note: You are running within a container, must bind to 0.0.0.0 (IPv4) or :: (IPv6) to publish the port.', flush=True)
552
+ elif not is_auth_enabled():
553
+ print(f' [tip] No password set. Any process on this machine can read sessions', flush=True)
554
+ print(f' and memory via the local API. Set HERMES_WEBUI_PASSWORD to', flush=True)
555
+ print(f' enable authentication.', flush=True)
556
+
557
+ ok, missing, errors = verify_hermes_imports()
558
+ if not ok and _HERMES_FOUND:
559
+ print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
560
+ for mod, err in errors.items():
561
+ print(f' {mod}: {err}', flush=True)
562
+ print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
563
+ auto_install_agent_deps()
564
+ ok, missing, errors = verify_hermes_imports()
565
+ if not ok:
566
+ print(f'[!!] Still missing after install attempt: {missing}', flush=True)
567
+ for mod, err in errors.items():
568
+ print(f' {mod}: {err}', flush=True)
569
+ print(' Agent features may not work correctly.', flush=True)
570
+ else:
571
+ print('[ok] Agent dependencies installed successfully.', flush=True)
572
+
573
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
574
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
575
+ DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
576
+
577
+ # Start the gateway session watcher for real-time SSE updates
578
+ try:
579
+ from api.gateway_watcher import start_watcher
580
+ start_watcher()
581
+ except Exception as e:
582
+ print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True)
583
+
584
+ httpd = QuietHTTPServer((HOST, PORT), Handler)
585
+
586
+ # ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
587
+ from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
588
+ scheme = 'https' if TLS_ENABLED else 'http'
589
+ if TLS_ENABLED:
590
+ try:
591
+ import ssl
592
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
593
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
594
+ ctx.load_cert_chain(TLS_CERT, TLS_KEY)
595
+ httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
596
+ print(f' TLS enabled: cert={TLS_CERT}, key={TLS_KEY}', flush=True)
597
+ except Exception as e:
598
+ print(f'[!!] WARNING: TLS setup failed ({e}), falling back to HTTP', flush=True)
599
+ scheme = 'http'
600
+
601
+ print(f' Hermes Web UI listening on {scheme}://{HOST}:{PORT}', flush=True)
602
+ if HOST in ('127.0.0.1', '::1') or within_container:
603
+ print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
604
+ print(f' Then open: {scheme}://localhost:{PORT}', flush=True)
605
+ print('', flush=True)
606
+ try:
607
+ httpd.serve_forever()
608
+ finally:
609
+ _log_shutdown_audit()
610
+ # Stop the gateway watcher on shutdown
611
+ try:
612
+ from api.gateway_watcher import stop_watcher
613
+ stop_watcher()
614
+ except Exception:
615
+ logger.debug("Failed to stop gateway watcher during shutdown")
616
+ # Drain pending memory-provider lifecycle commits before exit
617
+ try:
618
+ from api.session_lifecycle import drain_all_on_shutdown
619
+ drain_all_on_shutdown()
620
+ except Exception:
621
+ logger.debug("Failed to drain lifecycle on shutdown", exc_info=True)
622
+
623
+ if __name__ == '__main__':
624
+ main()