@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.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- 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
|