@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,255 @@
1
+ """Safe server-side probe for the official Hermes Agent dashboard.
2
+
3
+ The official `hermes dashboard` binds to 127.0.0.1:9119 by default and exposes
4
+ GET /api/status as a public, read-only identity/status endpoint. Keep all
5
+ probing server-side to avoid browser CORS/mixed-content failures, and only allow
6
+ loopback targets so a user-controlled setting cannot become an SSRF primitive.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import urllib.request
15
+ from urllib.parse import urlparse, urlunparse
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ DEFAULT_DASHBOARD_PORT = 9119
20
+ DEFAULT_DASHBOARD_TIMEOUT = 0.5
21
+ DEFAULT_DASHBOARD_TARGETS = (("127.0.0.1", DEFAULT_DASHBOARD_PORT), ("localhost", DEFAULT_DASHBOARD_PORT))
22
+ _DASHBOARD_ENABLED_VALUES = {"auto", "always", "never"}
23
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
24
+
25
+
26
+ def _base_url(host: str, port: int, scheme: str = "http") -> str:
27
+ display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
28
+ return f"{scheme}://{display_host}:{port}"
29
+
30
+
31
+ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | None:
32
+ """Return (host, port, scheme, base_url) for a safe loopback dashboard URL.
33
+
34
+ Overrides intentionally accept only scheme + loopback host + explicit port.
35
+ Paths, query strings, fragments, and credentials are rejected: the probe
36
+ appends the official `/api/status` fingerprint itself and must not become an
37
+ arbitrary local URL fetcher.
38
+ """
39
+ raw = str(raw_url or "").strip()
40
+ if not raw:
41
+ return None
42
+ parsed = urlparse(raw)
43
+ if parsed.scheme not in {"http", "https"}:
44
+ raise ValueError("invalid dashboard URL scheme")
45
+ if parsed.username or parsed.password:
46
+ raise ValueError("invalid dashboard URL credentials")
47
+ host = parsed.hostname or ""
48
+ normalized_host = host.strip().lower()
49
+ if normalized_host not in _LOOPBACK_HOSTS:
50
+ raise ValueError("invalid dashboard URL host")
51
+ try:
52
+ port = parsed.port
53
+ except ValueError as exc:
54
+ raise ValueError("invalid dashboard URL port") from exc
55
+ if not isinstance(port, int) or not (1 <= port <= 65535):
56
+ raise ValueError("invalid dashboard URL port")
57
+ path = parsed.path or ""
58
+ if path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
59
+ raise ValueError("invalid dashboard URL path")
60
+ base = _base_url(normalized_host, port, parsed.scheme)
61
+ return normalized_host, port, parsed.scheme, base
62
+
63
+
64
+ def normalize_dashboard_browser_url(raw_url: str | None) -> str:
65
+ """Return a safe browser-only dashboard link URL.
66
+
67
+ Unlike the server-side probe target, this value is only returned to the
68
+ browser for navigation. It may point at a public reverse-proxy hostname, but
69
+ it still rejects credentials, paths, query strings, fragments, and non-HTTP
70
+ schemes so it cannot hide secrets or script URLs in config.
71
+ """
72
+ raw = str(raw_url or "").strip()
73
+ if not raw:
74
+ return ""
75
+ parsed = urlparse(raw)
76
+ if parsed.scheme not in {"http", "https"}:
77
+ raise ValueError("invalid dashboard URL scheme")
78
+ if parsed.username or parsed.password:
79
+ raise ValueError("invalid dashboard URL credentials")
80
+ if not parsed.hostname:
81
+ raise ValueError("invalid dashboard URL host")
82
+ if parsed.params or parsed.query or parsed.fragment:
83
+ raise ValueError("invalid dashboard URL path")
84
+ path = parsed.path or ""
85
+ if path not in ("", "/"):
86
+ raise ValueError("invalid dashboard URL path")
87
+ try:
88
+ port = parsed.port
89
+ except ValueError as exc:
90
+ raise ValueError("invalid dashboard URL port") from exc
91
+ host = parsed.hostname.lower()
92
+ if ":" in host and not host.startswith("["):
93
+ host = f"[{host}]"
94
+ netloc = host
95
+ if port is not None:
96
+ if not (1 <= port <= 65535):
97
+ raise ValueError("invalid dashboard URL port")
98
+ netloc = f"{netloc}:{port}"
99
+ return urlunparse((parsed.scheme, netloc, "", "", "", ""))
100
+
101
+
102
+ def _looks_like_official_dashboard(payload: object) -> bool:
103
+ if not isinstance(payload, dict):
104
+ return False
105
+ version = payload.get("version")
106
+ if not isinstance(version, str) or not version.strip():
107
+ return False
108
+ # Verified against current Hermes Agent `hermes_cli.web_server.get_status()`:
109
+ # /api/status returns version plus these Hermes-specific fields. Requiring at
110
+ # least one avoids treating any generic {version: ...} local service as the
111
+ # official dashboard.
112
+ return any(key in payload for key in ("release_date", "hermes_home", "config_path", "gateway_running"))
113
+
114
+
115
+ def probe_official_dashboard(
116
+ host: str,
117
+ port: int,
118
+ timeout: float = DEFAULT_DASHBOARD_TIMEOUT,
119
+ scheme: str = "http",
120
+ ) -> dict:
121
+ """Best-effort check that `hermes dashboard` is running on host:port."""
122
+ try:
123
+ normalized_host = str(host or "").strip().lower()
124
+ if normalized_host not in _LOOPBACK_HOSTS:
125
+ raise ValueError("dashboard probe host must be loopback")
126
+ port = int(port)
127
+ if not (1 <= port <= 65535):
128
+ raise ValueError("dashboard probe port out of range")
129
+ if scheme not in {"http", "https"}:
130
+ raise ValueError("dashboard probe scheme must be http or https")
131
+ base = _base_url(normalized_host, port, scheme)
132
+ request = urllib.request.Request(
133
+ f"{base}/api/status",
134
+ headers={"Accept": "application/json", "User-Agent": "hermes-webui-dashboard-probe"},
135
+ )
136
+ with urllib.request.urlopen(request, timeout=timeout) as response:
137
+ if getattr(response, "status", None) != 200:
138
+ return {"running": False}
139
+ payload = json.loads(response.read().decode("utf-8"))
140
+ if not _looks_like_official_dashboard(payload):
141
+ return {"running": False}
142
+ result = {"running": True, "host": normalized_host, "port": port, "url": base}
143
+ version = payload.get("version")
144
+ if isinstance(version, str) and version.strip():
145
+ result["version"] = version.strip()
146
+ return result
147
+ except Exception:
148
+ logger.debug("official Hermes dashboard probe failed", exc_info=True)
149
+ return {"running": False}
150
+
151
+
152
+ def _dashboard_config(config_data: dict | None = None) -> dict:
153
+ if config_data is None:
154
+ try:
155
+ from api.config import get_config
156
+
157
+ config_data = get_config()
158
+ except Exception:
159
+ config_data = {}
160
+ webui_cfg = config_data.get("webui", {}) if isinstance(config_data, dict) else {}
161
+ dashboard_cfg = webui_cfg.get("dashboard", {}) if isinstance(webui_cfg, dict) else {}
162
+ return dashboard_cfg if isinstance(dashboard_cfg, dict) else {}
163
+
164
+
165
+ def get_dashboard_config(config_data: dict | None = None) -> dict:
166
+ """Return normalized profile config for the Settings → System controls."""
167
+ dashboard_cfg = _dashboard_config(config_data)
168
+ enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower()
169
+ if enabled not in _DASHBOARD_ENABLED_VALUES:
170
+ enabled = "auto"
171
+ raw_url = str(dashboard_cfg.get("url") or "").strip()
172
+ if raw_url:
173
+ raw_url = normalize_dashboard_browser_url(raw_url)
174
+ return {"enabled": enabled, "url": raw_url}
175
+
176
+
177
+ def save_dashboard_config(payload: dict) -> dict:
178
+ """Persist dashboard link settings under webui.dashboard in config.yaml."""
179
+ enabled = str((payload or {}).get("enabled", "auto") or "auto").strip().lower()
180
+ if enabled not in _DASHBOARD_ENABLED_VALUES:
181
+ raise ValueError("invalid dashboard enabled mode")
182
+ raw_url = str((payload or {}).get("url", "") or "").strip()
183
+ normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
184
+
185
+ from api import config as webui_config
186
+
187
+ config_path = webui_config._get_config_path()
188
+ config_data = webui_config._load_yaml_config_file(config_path)
189
+ webui_section = config_data.get("webui")
190
+ if not isinstance(webui_section, dict):
191
+ webui_section = {}
192
+ config_data["webui"] = webui_section
193
+ dashboard_section = webui_section.get("dashboard")
194
+ if not isinstance(dashboard_section, dict):
195
+ dashboard_section = {}
196
+ webui_section["dashboard"] = dashboard_section
197
+ dashboard_section["enabled"] = enabled
198
+ if normalized_url:
199
+ dashboard_section["url"] = normalized_url
200
+ else:
201
+ dashboard_section.pop("url", None)
202
+ webui_config._save_yaml_config_file(config_path, config_data)
203
+ webui_config.reload_config()
204
+ return {"enabled": enabled, "url": normalized_url}
205
+
206
+
207
+ def _webui_bind_host_allows_auto_probe() -> bool:
208
+ raw_host = str(os.environ.get("HERMES_WEBUI_HOST") or "127.0.0.1").strip().lower()
209
+ host = raw_host.replace("[", "").replace("]", "")
210
+ return host in _LOOPBACK_HOSTS
211
+
212
+
213
+ def get_dashboard_status(config_data: dict | None = None) -> dict:
214
+ """Return the safe status payload consumed by GET /api/dashboard/status."""
215
+ dashboard_cfg = _dashboard_config(config_data)
216
+ enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower()
217
+ if enabled not in _DASHBOARD_ENABLED_VALUES:
218
+ enabled = "auto"
219
+ if enabled == "never":
220
+ return {"running": False, "enabled": "never"}
221
+
222
+ raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
223
+ try:
224
+ browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
225
+ except ValueError:
226
+ return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}
227
+ try:
228
+ override = normalize_dashboard_url(raw_url)
229
+ except ValueError:
230
+ override = None
231
+
232
+ targets: list[tuple[str, int, str, str]]
233
+ if override:
234
+ targets = [override]
235
+ else:
236
+ targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS]
237
+
238
+ if enabled == "always":
239
+ if browser_url and not override:
240
+ return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url}
241
+ host, port, scheme, base = targets[0]
242
+ return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base}
243
+
244
+ if not _webui_bind_host_allows_auto_probe():
245
+ return {"running": False, "enabled": enabled}
246
+
247
+ for host, port, scheme, _base in targets:
248
+ result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme)
249
+ if result.get("running"):
250
+ result["enabled"] = enabled
251
+ if browser_url:
252
+ result["browser_url"] = browser_url
253
+ result["url"] = browser_url
254
+ return result
255
+ return {"running": False, "enabled": enabled}
@@ -0,0 +1,253 @@
1
+ """Opt-in WebUI extension hooks.
2
+
3
+ This module intentionally provides a small, self-hosted extension surface:
4
+ configured same-origin script/style injection plus sandboxed static file serving.
5
+ It is disabled by default and never executes or fetches third-party URLs.
6
+ """
7
+
8
+ import html
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+ from urllib.parse import unquote, urlsplit
14
+
15
+ from api.config import REPO_ROOT
16
+ from api.helpers import _security_headers, j
17
+
18
+ _log = logging.getLogger(__name__)
19
+
20
+ # Sane bound on configured URLs — real extensions ship 1-3 files. Higher values
21
+ # typically indicate a misconfiguration (one giant unsplit string, or a runaway
22
+ # generator script that wrote an env-var template without filtering). Capping
23
+ # avoids rendering tens of thousands of <script> tags into every page load.
24
+ _MAX_URL_LIST = 32
25
+
26
+ # Tracks rejected URL strings we've already warned about so a misconfigured env
27
+ # var doesn't spam the log on every request that re-reads it.
28
+ _warned_urls: set = set()
29
+
30
+ EXTENSION_ROUTE_PREFIX = "/extensions/"
31
+ _EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR"
32
+ _EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"
33
+ _EXTENSION_STYLESHEET_URLS_ENV = "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS"
34
+ _ALLOWED_ASSET_PREFIXES = ("/extensions/", "/static/")
35
+
36
+ _EXTENSION_MIME = {
37
+ "css": "text/css",
38
+ "js": "application/javascript",
39
+ "html": "text/html",
40
+ "svg": "image/svg+xml",
41
+ "png": "image/png",
42
+ "jpg": "image/jpeg",
43
+ "jpeg": "image/jpeg",
44
+ "ico": "image/x-icon",
45
+ "gif": "image/gif",
46
+ "webp": "image/webp",
47
+ "woff": "font/woff",
48
+ "woff2": "font/woff2",
49
+ "ttf": "font/ttf",
50
+ "otf": "font/otf",
51
+ "wasm": "application/wasm",
52
+ }
53
+ _TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"}
54
+
55
+
56
+ def _extension_root() -> Optional[Path]:
57
+ """Return the configured extension directory, or None when disabled.
58
+
59
+ A missing or non-directory path disables extensions instead of failing open.
60
+ The startup docs encourage users to point this at a directory they control.
61
+
62
+ Relative paths are resolved against REPO_ROOT so they work regardless of
63
+ the process working directory (ctl.sh start, bootstrap.py, etc.).
64
+ """
65
+ raw = os.getenv(_EXTENSION_DIR_ENV, "").strip()
66
+ if not raw:
67
+ return None
68
+ path = Path(raw).expanduser()
69
+ if not path.is_absolute():
70
+ path = REPO_ROOT / path
71
+ root = path.resolve()
72
+ if not root.exists() or not root.is_dir():
73
+ return None
74
+ return root
75
+
76
+
77
+ def _fully_unquote_path(path: str) -> str:
78
+ """Decode percent-encoding until stable so encoded dot-segments cannot hide.
79
+
80
+ Iterates up to 10 times so even quadruple-encoded inputs like
81
+ ``%2525252e%2525252e`` collapse to literal ``..`` and are rejected by
82
+ the segment-level safety check downstream. URL strings stabilize in
83
+ fewer than 5 iterations in practice; the cap is defensive.
84
+ """
85
+ previous = path
86
+ for _ in range(10):
87
+ current = unquote(previous)
88
+ if current == previous:
89
+ return current
90
+ previous = current
91
+ return previous
92
+
93
+
94
+ def _is_safe_asset_url(value: str) -> bool:
95
+ """Allow only same-origin extension/static asset URLs.
96
+
97
+ External schemes, protocol-relative URLs, fragments, arbitrary API paths, and
98
+ encoded traversal are rejected so enabling extensions does not require
99
+ loosening the CSP.
100
+ """
101
+ if not value or any(ch in value for ch in ('\x00', '\r', '\n', '"', "'", "<", ">", "\\")):
102
+ return False
103
+ parsed = urlsplit(value)
104
+ if parsed.scheme or parsed.netloc or parsed.fragment:
105
+ return False
106
+
107
+ decoded_path = _fully_unquote_path(parsed.path)
108
+ if not any(decoded_path.startswith(prefix) for prefix in _ALLOWED_ASSET_PREFIXES):
109
+ return False
110
+
111
+ for prefix in _ALLOWED_ASSET_PREFIXES:
112
+ if decoded_path.startswith(prefix):
113
+ return _is_safe_relative_path(decoded_path[len(prefix) :])
114
+ return False
115
+
116
+
117
+ def _read_url_list(env_name: str) -> List[str]:
118
+ raw = os.getenv(env_name, "")
119
+ urls = []
120
+ for item in raw.split(","):
121
+ value = item.strip()
122
+ if not value:
123
+ continue
124
+ if _is_safe_asset_url(value):
125
+ urls.append(value)
126
+ if len(urls) >= _MAX_URL_LIST:
127
+ # Stop accumulating after the cap. Anything past this point
128
+ # would be silently dropped anyway; logging once makes the
129
+ # truncation visible to a confused operator.
130
+ if env_name not in _warned_urls:
131
+ _warned_urls.add(env_name)
132
+ _log.warning(
133
+ "Extension URL list %s truncated at %d entries",
134
+ env_name, _MAX_URL_LIST,
135
+ )
136
+ break
137
+ elif value not in _warned_urls:
138
+ # First-time-seen invalid URL: log once per process so a typo
139
+ # in HERMES_WEBUI_EXTENSION_*_URLS doesn't disappear silently.
140
+ _warned_urls.add(value)
141
+ _log.warning(
142
+ "Rejected extension URL %r from %s (not a same-origin "
143
+ "/extensions/ or /static/ path, or contains unsafe chars)",
144
+ value, env_name,
145
+ )
146
+ return urls
147
+
148
+
149
+ def get_extension_config() -> Dict[str, object]:
150
+ """Return public extension config without exposing filesystem paths."""
151
+ enabled = _extension_root() is not None
152
+ if not enabled:
153
+ return {"enabled": False, "script_urls": [], "stylesheet_urls": []}
154
+ return {
155
+ "enabled": True,
156
+ "script_urls": _read_url_list(_EXTENSION_SCRIPT_URLS_ENV),
157
+ "stylesheet_urls": _read_url_list(_EXTENSION_STYLESHEET_URLS_ENV),
158
+ }
159
+
160
+
161
+ def inject_extension_tags(index_html: str) -> str:
162
+ """Inject configured extension tags into the app shell.
163
+
164
+ Tags are inserted only when the extension directory is enabled. URLs are
165
+ escaped even though they are already validated, keeping the renderer robust
166
+ if validation rules evolve later.
167
+ """
168
+ config = get_extension_config()
169
+ if not config["enabled"]:
170
+ return index_html
171
+
172
+ result = index_html
173
+ stylesheet_tags = [
174
+ '<link rel="stylesheet" href="{}">'.format(html.escape(url, quote=True))
175
+ for url in config["stylesheet_urls"]
176
+ ]
177
+ script_tags = [
178
+ '<script src="{}" defer></script>'.format(html.escape(url, quote=True))
179
+ for url in config["script_urls"]
180
+ ]
181
+
182
+ if stylesheet_tags:
183
+ head_marker = "</head>"
184
+ block = "\n".join(stylesheet_tags) + "\n"
185
+ if head_marker in result:
186
+ result = result.replace(head_marker, block + head_marker, 1)
187
+ else:
188
+ result = block + result
189
+
190
+ if script_tags:
191
+ body_marker = "</body>"
192
+ block = "\n".join(script_tags) + "\n"
193
+ if body_marker in result:
194
+ result = result.replace(body_marker, block + body_marker, 1)
195
+ else:
196
+ result = result + "\n" + block
197
+
198
+ return result
199
+
200
+
201
+ def _is_safe_relative_path(rel: str) -> bool:
202
+ if not rel or "\x00" in rel or "\\" in rel:
203
+ return False
204
+ for segment in rel.split("/"):
205
+ if not segment or segment in (".", "..") or segment.startswith("."):
206
+ return False
207
+ return True
208
+
209
+
210
+ def _not_found(handler) -> bool:
211
+ j(handler, {"error": "not found"}, status=404)
212
+ return True
213
+
214
+
215
+ def serve_extension_static(handler, parsed) -> bool:
216
+ """Serve a file from the configured extension directory.
217
+
218
+ The function always returns True for /extensions/* requests: either a file
219
+ response or a 404. It never reveals why a request failed, which avoids
220
+ leaking local paths or extension configuration details.
221
+ """
222
+ root = _extension_root()
223
+ if root is None:
224
+ return _not_found(handler)
225
+
226
+ rel = unquote(parsed.path[len(EXTENSION_ROUTE_PREFIX) :])
227
+ if not _is_safe_relative_path(rel):
228
+ return _not_found(handler)
229
+
230
+ static_file = (root / rel).resolve()
231
+ try:
232
+ static_file.relative_to(root)
233
+ except ValueError:
234
+ return _not_found(handler)
235
+
236
+ if not static_file.exists() or not static_file.is_file():
237
+ return _not_found(handler)
238
+
239
+ ct = _EXTENSION_MIME.get(static_file.suffix.lower().lstrip("."), "text/plain")
240
+ ct_header = "{}; charset=utf-8".format(ct) if ct in _TEXT_MIME_TYPES else ct
241
+ try:
242
+ raw = static_file.read_bytes()
243
+ except OSError:
244
+ return _not_found(handler)
245
+
246
+ handler.send_response(200)
247
+ handler.send_header("Content-Type", ct_header)
248
+ handler.send_header("Cache-Control", "no-store")
249
+ handler.send_header("Content-Length", str(len(raw)))
250
+ _security_headers(handler)
251
+ handler.end_headers()
252
+ handler.wfile.write(raw)
253
+ return True