@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,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()
|