F4bC0d3 commited on
Commit ·
5f92103
0
Parent(s):
Initial release: HuggingMes + Hermes WebUI integration
Browse files- .dockerignore +14 -0
- .env.example +38 -0
- .gitignore +11 -0
- Dockerfile +111 -0
- LICENSE +36 -0
- README.md +202 -0
- cloudflare-keepalive-setup.py +225 -0
- cloudflare-proxy-setup.py +203 -0
- health-server.js +928 -0
- hermes-sync.py +337 -0
- start.sh +428 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.dockerignore
|
| 4 |
+
.env
|
| 5 |
+
.env.*
|
| 6 |
+
!.env.example
|
| 7 |
+
.kiro
|
| 8 |
+
.venv
|
| 9 |
+
venv
|
| 10 |
+
__pycache__
|
| 11 |
+
*.pyc
|
| 12 |
+
*.pyo
|
| 13 |
+
*.log
|
| 14 |
+
node_modules
|
.env.example
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Required ──────────────────────────────────────────────────────────
|
| 2 |
+
GATEWAY_TOKEN=change-me-to-a-strong-random-string
|
| 3 |
+
LLM_API_KEY=your-llm-provider-api-key
|
| 4 |
+
LLM_MODEL=openrouter/anthropic/claude-sonnet-4
|
| 5 |
+
|
| 6 |
+
# Examples for other providers:
|
| 7 |
+
# LLM_MODEL=openai/gpt-4o
|
| 8 |
+
# LLM_MODEL=anthropic/claude-sonnet-4-6
|
| 9 |
+
# LLM_MODEL=google/gemini-2.5-flash
|
| 10 |
+
# LLM_MODEL=deepseek/deepseek-chat
|
| 11 |
+
# LLM_MODEL=huggingface/Qwen/Qwen3-235B-A22B-Thinking-2507
|
| 12 |
+
|
| 13 |
+
# ── HF Dataset persistence (recommended) ──────────────────────────────
|
| 14 |
+
# Write-scope token: https://huggingface.co/settings/tokens
|
| 15 |
+
# HF_TOKEN=hf_xxx
|
| 16 |
+
# BACKUP_DATASET_NAME=huggingmes-backup
|
| 17 |
+
# SYNC_INTERVAL=600
|
| 18 |
+
|
| 19 |
+
# ── Optional: Cloudflare proxy + keep-alive ───────────────────────────
|
| 20 |
+
# CLOUDFLARE_WORKERS_TOKEN=cf_xxx
|
| 21 |
+
# CLOUDFLARE_ACCOUNT_ID=
|
| 22 |
+
# CLOUDFLARE_KEEPALIVE_CRON=*/10 * * * *
|
| 23 |
+
|
| 24 |
+
# ── Optional: Telegram bridge ─────────────────────────────────────────
|
| 25 |
+
# TELEGRAM_BOT_TOKEN=123456:ABC
|
| 26 |
+
# TELEGRAM_ALLOWED_USERS=11111111,22222222
|
| 27 |
+
# TELEGRAM_MODE=webhook
|
| 28 |
+
|
| 29 |
+
# ── Optional: swap the landing page to the HuggingMes status page ─────
|
| 30 |
+
# PRIMARY_UI=dashboard
|
| 31 |
+
|
| 32 |
+
# ── Optional: custom OpenAI-compatible endpoint ───────────────────────
|
| 33 |
+
# CUSTOM_BASE_URL=http://localhost:11434/v1
|
| 34 |
+
# CUSTOM_MODEL_CONTEXT_LENGTH=131072
|
| 35 |
+
# CUSTOM_MODEL_MAX_TOKENS=8192
|
| 36 |
+
|
| 37 |
+
# ── Reproducibility: pin the Hermes Agent base image ──────────────────
|
| 38 |
+
# HERMES_AGENT_VERSION=latest
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
!.env.example
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
.venv/
|
| 8 |
+
.cache/
|
| 9 |
+
*.log
|
| 10 |
+
*.pid
|
| 11 |
+
*.tmp
|
Dockerfile
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingMes + Hermes WebUI — merged deployment for Hugging Face Spaces
|
| 2 |
+
# Base: NousResearch Hermes Agent (ships Hermes CLI, gateway, dashboard, Python venv)
|
| 3 |
+
|
| 4 |
+
ARG HERMES_AGENT_VERSION=latest
|
| 5 |
+
FROM nousresearch/hermes-agent:${HERMES_AGENT_VERSION}
|
| 6 |
+
|
| 7 |
+
ARG WEBUI_REF=master
|
| 8 |
+
|
| 9 |
+
USER root
|
| 10 |
+
|
| 11 |
+
# System deps (mirrors HuggingMes) + git/nodejs for WebUI checkout + router
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
ca-certificates \
|
| 14 |
+
curl \
|
| 15 |
+
jq \
|
| 16 |
+
git \
|
| 17 |
+
python3 \
|
| 18 |
+
nodejs \
|
| 19 |
+
npm \
|
| 20 |
+
chromium \
|
| 21 |
+
libnss3 \
|
| 22 |
+
libatk1.0-0 \
|
| 23 |
+
libatk-bridge2.0-0 \
|
| 24 |
+
libdrm2 \
|
| 25 |
+
libgbm1 \
|
| 26 |
+
libxcomposite1 \
|
| 27 |
+
libxdamage1 \
|
| 28 |
+
libxrandr2 \
|
| 29 |
+
libxkbcommon0 \
|
| 30 |
+
libx11-6 \
|
| 31 |
+
libxext6 \
|
| 32 |
+
libxfixes3 \
|
| 33 |
+
libasound2 \
|
| 34 |
+
fonts-dejavu-core \
|
| 35 |
+
fonts-liberation \
|
| 36 |
+
fonts-noto-color-emoji \
|
| 37 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 38 |
+
&& uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir \
|
| 39 |
+
huggingface_hub hf_transfer pyyaml
|
| 40 |
+
|
| 41 |
+
# Clone nesquena/hermes-webui (install deps into the agent venv so imports resolve)
|
| 42 |
+
RUN git clone --depth 1 --branch ${WEBUI_REF} \
|
| 43 |
+
https://github.com/nesquena/hermes-webui.git /opt/hermes-webui \
|
| 44 |
+
&& ( [ -f /opt/hermes-webui/requirements.txt ] \
|
| 45 |
+
&& /opt/hermes/.venv/bin/pip install --no-cache-dir -r /opt/hermes-webui/requirements.txt \
|
| 46 |
+
|| true ) \
|
| 47 |
+
&& chown -R hermes:hermes /opt/hermes-webui
|
| 48 |
+
|
| 49 |
+
# HuggingMes-style integration scripts (vendored from somratpro/HuggingMes)
|
| 50 |
+
COPY --chown=hermes:hermes start.sh /opt/huggingmes/start.sh
|
| 51 |
+
COPY --chown=hermes:hermes health-server.js /opt/huggingmes/health-server.js
|
| 52 |
+
COPY --chown=hermes:hermes hermes-sync.py /opt/huggingmes/hermes-sync.py
|
| 53 |
+
COPY --chown=hermes:hermes cloudflare-proxy-setup.py /opt/huggingmes/cloudflare-proxy-setup.py
|
| 54 |
+
COPY --chown=hermes:hermes cloudflare-keepalive-setup.py /opt/huggingmes/cloudflare-keepalive-setup.py
|
| 55 |
+
|
| 56 |
+
RUN chmod +x \
|
| 57 |
+
/opt/huggingmes/start.sh \
|
| 58 |
+
/opt/huggingmes/hermes-sync.py \
|
| 59 |
+
/opt/huggingmes/cloudflare-proxy-setup.py \
|
| 60 |
+
/opt/huggingmes/cloudflare-keepalive-setup.py
|
| 61 |
+
|
| 62 |
+
# Idempotent kanban migration patch (same workaround HuggingMes ships)
|
| 63 |
+
RUN python3 - <<'PY'
|
| 64 |
+
from pathlib import Path
|
| 65 |
+
import sys
|
| 66 |
+
p = Path("/opt/hermes/hermes_cli/kanban_db.py")
|
| 67 |
+
if not p.exists():
|
| 68 |
+
sys.exit(0)
|
| 69 |
+
src = p.read_text(encoding="utf-8")
|
| 70 |
+
sentinel = "# huggingmes-webui: idempotent-alter"
|
| 71 |
+
if sentinel in src:
|
| 72 |
+
sys.exit(0)
|
| 73 |
+
old = (
|
| 74 |
+
' conn.execute(\n'
|
| 75 |
+
' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
|
| 76 |
+
' "INTEGER NOT NULL DEFAULT 0"\n'
|
| 77 |
+
' )'
|
| 78 |
+
)
|
| 79 |
+
new = (
|
| 80 |
+
f' try: {sentinel}\n'
|
| 81 |
+
' conn.execute(\n'
|
| 82 |
+
' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
|
| 83 |
+
' "INTEGER NOT NULL DEFAULT 0"\n'
|
| 84 |
+
' )\n'
|
| 85 |
+
' except Exception:\n'
|
| 86 |
+
' pass'
|
| 87 |
+
)
|
| 88 |
+
if old in src:
|
| 89 |
+
p.write_text(src.replace(old, new), encoding="utf-8")
|
| 90 |
+
print("kanban patch: applied")
|
| 91 |
+
PY
|
| 92 |
+
|
| 93 |
+
# Keep hermes CLI on PATH for all shell types (login/interactive/non-interactive)
|
| 94 |
+
RUN echo 'export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"' \
|
| 95 |
+
> /etc/profile.d/hermes-venv.sh
|
| 96 |
+
|
| 97 |
+
ENV HERMES_HOME=/opt/data \
|
| 98 |
+
HUGGINGMES_APP_DIR=/opt/huggingmes \
|
| 99 |
+
HERMES_WEBUI_REPO=/opt/hermes-webui \
|
| 100 |
+
HERMES_AGENT_VERSION=${HERMES_AGENT_VERSION} \
|
| 101 |
+
PYTHONUNBUFFERED=1 \
|
| 102 |
+
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
| 103 |
+
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
| 104 |
+
|
| 105 |
+
EXPOSE 7861
|
| 106 |
+
|
| 107 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s \
|
| 108 |
+
CMD curl -fsS http://localhost:7861/health || exit 1
|
| 109 |
+
|
| 110 |
+
USER hermes
|
| 111 |
+
CMD ["/opt/huggingmes/start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 F4bC0d3
|
| 4 |
+
|
| 5 |
+
This project is a deployment recipe that combines three upstream open source
|
| 6 |
+
projects:
|
| 7 |
+
- Hermes Agent by Nous Research (MIT) — https://github.com/NousResearch/hermes-agent
|
| 8 |
+
- Hermes WebUI by Nicolas Esquerre (MIT) — https://github.com/nesquena/hermes-webui
|
| 9 |
+
- HuggingMes by somratpro (MIT) — https://github.com/somratpro/HuggingMes
|
| 10 |
+
|
| 11 |
+
The integration layer (Dockerfile, health-server.js routing additions for
|
| 12 |
+
Hermes WebUI, start.sh launch sequence for the WebUI subprocess, README) is
|
| 13 |
+
released under the MIT License below. The vendored files
|
| 14 |
+
(hermes-sync.py, cloudflare-proxy-setup.py, cloudflare-keepalive-setup.py,
|
| 15 |
+
plus large parts of start.sh and health-server.js) remain under their
|
| 16 |
+
original MIT licenses from the upstream HuggingMes project.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 21 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 22 |
+
in the Software without restriction, including without limitation the rights
|
| 23 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 24 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 25 |
+
furnished to do so, subject to the following conditions:
|
| 26 |
+
|
| 27 |
+
The above copyright notice and this permission notice shall be included in all
|
| 28 |
+
copies or substantial portions of the Software.
|
| 29 |
+
|
| 30 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 31 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 32 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 33 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 34 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 35 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 36 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HuggingMes Hermes WebUI
|
| 3 |
+
emoji: ??
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7861
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
# 🪽 HuggingMes + Hermes WebUI
|
| 12 |
+
|
| 13 |
+
> A merged Hugging Face Space that runs [Hermes Agent](https://github.com/NousResearch/hermes-agent) with [Hermes WebUI](https://github.com/nesquena/hermes-webui) as the primary chat interface. Your own self-hosted AI agent, on free HF Space hardware.
|
| 14 |
+
|
| 15 |
+
**This project is not original work.** It's a deployment recipe that combines three excellent existing projects into a single Space:
|
| 16 |
+
|
| 17 |
+
- **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** by **[Nous Research](https://nousresearch.com)** — the actual AI agent (memory, tools, scheduling, multi-provider LLM support). All the intelligence comes from here.
|
| 18 |
+
- **[Hermes WebUI](https://github.com/nesquena/hermes-webui)** by **[@nesquena](https://github.com/nesquena)** — the three-panel browser UI (sessions, chat, workspace files) with SSE streaming, slash commands, profiles, themes, voice input, file browser, and 100+ other features. Used as the primary chat surface.
|
| 19 |
+
- **[HuggingMes](https://github.com/somratpro/HuggingMes)** by **[@somratpro](https://github.com/somratpro)** — the HF Space wrapper: Docker image, gateway auth, HF Dataset persistence, Cloudflare proxy/keep-alive, Telegram bridge.
|
| 20 |
+
|
| 21 |
+
This repo just glues them together so both UIs share one port, one auth token, and one persistence layer on a single HF Space. Full credit goes to the upstream maintainers.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Try it in 30 seconds
|
| 26 |
+
|
| 27 |
+
I keep a live demo at [huggingface.co/spaces/f4b404/hermes](https://huggingface.co/spaces/f4b404/hermes). Click **Duplicate this Space** at the top right to fork it into your own account.
|
| 28 |
+
|
| 29 |
+
After duplicating, you only need to set 3 secrets to get running. See the next section.
|
| 30 |
+
|
| 31 |
+
## Setting up your own Space
|
| 32 |
+
|
| 33 |
+
### 1. Duplicate the Space
|
| 34 |
+
|
| 35 |
+
Go to [huggingface.co/spaces/f4b404/hermes](https://huggingface.co/spaces/f4b404/hermes) → click the **⋮** menu (top-right corner) → **Duplicate this Space**. Name it whatever you want, pick CPU basic free hardware, decide public/private. HF copies all files automatically.
|
| 36 |
+
|
| 37 |
+
If you'd rather start from this repo, create a new Space with **SDK = Docker** at [huggingface.co/new-space](https://huggingface.co/new-space) and upload everything in this repo to its `main` branch.
|
| 38 |
+
|
| 39 |
+
### 2. Add secrets (Settings → Variables and secrets)
|
| 40 |
+
|
| 41 |
+
**Required** (the Space will not start without these):
|
| 42 |
+
|
| 43 |
+
| Secret | What it is | Example |
|
| 44 |
+
| --- | --- | --- |
|
| 45 |
+
| `GATEWAY_TOKEN` | Your password — gates the WebUI login and the `/v1/*` API. Pick anything strong. | A 32-char random string (`openssl rand -base64 32`) |
|
| 46 |
+
| `LLM_API_KEY` | API key for whichever LLM provider you want to use | `sk-or-v1-...` for OpenRouter, `sk-...` for OpenAI, etc. |
|
| 47 |
+
| `LLM_MODEL` | HuggingMes-style model identifier | `openrouter/anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-flash`, `huggingface/Qwen/Qwen3-235B-A22B-Thinking-2507`, etc. |
|
| 48 |
+
|
| 49 |
+
**Recommended**:
|
| 50 |
+
|
| 51 |
+
| Secret | Why | How to get |
|
| 52 |
+
| --- | --- | --- |
|
| 53 |
+
| `HF_TOKEN` | Persists your sessions, profiles, skills, cron jobs, memory, and workspace files across Space restarts by syncing to a private HF Dataset every 10 min. **Without this, restarts wipe everything.** | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) → New token → **Write** scope |
|
| 54 |
+
| `BACKUP_DATASET_NAME` | If you run multiple instances of this Space (or any other HuggingMes-derived project), set a unique name here so they don't overwrite each other's backups. | e.g. `my-hermes-backup` |
|
| 55 |
+
|
| 56 |
+
**Optional advanced features**:
|
| 57 |
+
|
| 58 |
+
| Secret | What it does |
|
| 59 |
+
| --- | --- |
|
| 60 |
+
| `CLOUDFLARE_WORKERS_TOKEN` | Auto-provisions two Cloudflare Workers: one as an outbound proxy (needed for Telegram, sometimes for blocked LLM providers) and one as a cron keep-alive worker that pings `/health` every 10 min so the Space doesn't sleep on free tier |
|
| 61 |
+
| `CLOUDFLARE_ACCOUNT_ID` | Explicit Cloudflare account ID if you have multiple |
|
| 62 |
+
| `TELEGRAM_BOT_TOKEN` | Enables the Telegram bridge so you can chat with Hermes from Telegram |
|
| 63 |
+
| `TELEGRAM_ALLOWED_USERS` | Comma-separated numeric Telegram user IDs allowed to use the bot |
|
| 64 |
+
| `PRIMARY_UI` | Set to `dashboard` to make `/` show the HuggingMes status page instead of the chat UI. Default is `webui`. |
|
| 65 |
+
| `SYNC_INTERVAL` | Backup cadence in seconds (default 600, range 60–86400) |
|
| 66 |
+
| `HERMES_AGENT_VERSION` | Pin the upstream Hermes Agent base image to a specific tag for reproducibility (default `latest`) |
|
| 67 |
+
|
| 68 |
+
### 3. Restart and open
|
| 69 |
+
|
| 70 |
+
Settings → **Restart this Space** (or **Factory reboot** if you changed the Dockerfile). Wait ~5–8 minutes for the first build. Watch the **Logs** tab — when you see this, you're ready:
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
HuggingMes + Hermes WebUI router listening on 0.0.0.0:7861
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Open the Space URL (`https://<you>-<name>.hf.space`) in a **new tab** (the embedded HF iframe sometimes blocks the login cookie). You'll see a login page → enter your `GATEWAY_TOKEN` → the chat UI loads.
|
| 77 |
+
|
| 78 |
+
> **Tip**: bookmark the direct `*.hf.space` URL rather than the HF page — much smoother on mobile and avoids iframe quirks.
|
| 79 |
+
|
| 80 |
+
## What you can do once it's running
|
| 81 |
+
|
| 82 |
+
| URL | What's there |
|
| 83 |
+
| --- | --- |
|
| 84 |
+
| `/` | **Hermes WebUI** — three-panel chat with sessions, file browser, slash commands, profiles, themes, voice input, mermaid diagrams, syntax highlighting, tool cards, and everything else hermes-webui ships |
|
| 85 |
+
| `/hm` | HuggingMes status dashboard — gateway/WebUI/backup/Telegram/keepalive tiles |
|
| 86 |
+
| `/hm/app/` | Hermes's built-in dashboard — manage providers, profiles, cron jobs |
|
| 87 |
+
| `/v1/*` | OpenAI-compatible API — point any OpenAI SDK at it with `GATEWAY_TOKEN` as the API key |
|
| 88 |
+
| `/health` | JSON health probe — no auth, used by HF Spaces and the Cloudflare keepalive worker |
|
| 89 |
+
| `/telegram` | Telegram bot webhook (only if `TELEGRAM_BOT_TOKEN` is set) |
|
| 90 |
+
|
| 91 |
+
### Using the API from code
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
curl https://<you>-<name>.hf.space/v1/chat/completions \
|
| 95 |
+
-H "Authorization: Bearer $GATEWAY_TOKEN" \
|
| 96 |
+
-H "Content-Type: application/json" \
|
| 97 |
+
-d '{
|
| 98 |
+
"model": "hermes",
|
| 99 |
+
"messages": [{"role": "user", "content": "hello"}]
|
| 100 |
+
}'
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
```python
|
| 104 |
+
from openai import OpenAI
|
| 105 |
+
client = OpenAI(
|
| 106 |
+
base_url="https://<you>-<name>.hf.space/v1",
|
| 107 |
+
api_key="<your GATEWAY_TOKEN>",
|
| 108 |
+
)
|
| 109 |
+
resp = client.chat.completions.create(
|
| 110 |
+
model="hermes",
|
| 111 |
+
messages=[{"role": "user", "content": "hello"}],
|
| 112 |
+
)
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### Adding MCP servers
|
| 116 |
+
|
| 117 |
+
Open `/hm/app/config` (the Hermes config editor) and add an `mcp` block. No SSH needed:
|
| 118 |
+
|
| 119 |
+
```yaml
|
| 120 |
+
mcp:
|
| 121 |
+
servers:
|
| 122 |
+
fetch:
|
| 123 |
+
command: uvx
|
| 124 |
+
args: ["mcp-server-fetch"]
|
| 125 |
+
filesystem:
|
| 126 |
+
command: npx
|
| 127 |
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/opt/data/workspace"]
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
`uvx` and `npx` are both pre-installed in the image.
|
| 131 |
+
|
| 132 |
+
## Persistence and how it works
|
| 133 |
+
|
| 134 |
+
When `HF_TOKEN` is set:
|
| 135 |
+
|
| 136 |
+
- **On boot**, the Space downloads the latest snapshot from your private HF Dataset (default name `huggingmes-backup`) and restores it into `/opt/data/`.
|
| 137 |
+
- **Every `SYNC_INTERVAL` seconds** (default 600), it detects state changes and uploads a new snapshot.
|
| 138 |
+
- **On graceful shutdown** (SIGTERM), it does one final sync before exit.
|
| 139 |
+
|
| 140 |
+
What gets backed up: chat sessions, agent memory, workspace files, profiles, skills, cron jobs, Hermes config. The dataset is private to your HF account.
|
| 141 |
+
|
| 142 |
+
## Architecture
|
| 143 |
+
|
| 144 |
+
Single port (7861) Node.js router fronts four backends:
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
HF Space port 7861
|
| 148 |
+
│
|
| 149 |
+
▼
|
| 150 |
+
health-server.js (router + auth + status page)
|
| 151 |
+
│
|
| 152 |
+
├─► / → Hermes WebUI (127.0.0.1:8787)
|
| 153 |
+
├─► /hm → HuggingMes status (in-process)
|
| 154 |
+
├─► /hm/app/* → Hermes dashboard (127.0.0.1:9119) [SPA-rewritten]
|
| 155 |
+
├─► /v1/* → Hermes gateway API (127.0.0.1:8642) [bearer auth]
|
| 156 |
+
├─► /telegram → Telegram webhook (127.0.0.1:8765)
|
| 157 |
+
└─► /health, /status → in-process JSON
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
`start.sh` boots Hermes Agent's gateway + dashboard + WebUI as subprocesses, then the router on top. `hermes-sync.py` runs the periodic HF Dataset upload loop. Cloudflare and Telegram setup runs once at boot if their respective secrets are set.
|
| 161 |
+
|
| 162 |
+
## Local testing
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
git clone https://github.com/F4bC0d3/huggingmes-hermes-webui.git
|
| 166 |
+
cd huggingmes-hermes-webui
|
| 167 |
+
cp .env.example .env
|
| 168 |
+
# edit .env with real GATEWAY_TOKEN, LLM_API_KEY, LLM_MODEL
|
| 169 |
+
docker build -t huggingmes-hermes-webui .
|
| 170 |
+
docker run --rm -p 7861:7861 --env-file .env huggingmes-hermes-webui
|
| 171 |
+
# open http://localhost:7861
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## Troubleshooting
|
| 175 |
+
|
| 176 |
+
| Symptom | Cause / Fix |
|
| 177 |
+
| --- | --- |
|
| 178 |
+
| Build fails on `nousresearch/hermes-agent:latest` | Set `HERMES_AGENT_VERSION` to a specific tag and restart |
|
| 179 |
+
| Container Running but `/` returns 502 | Hermes WebUI didn't bind. Check Logs tab for `webui.log` output — usually missing/wrong `LLM_API_KEY` |
|
| 180 |
+
| `/v1/*` returns 401 | Need `Authorization: Bearer <GATEWAY_TOKEN>` header |
|
| 181 |
+
| `/api/status` 404s in logs | Cosmetic — old browser tab polling. Ignored. |
|
| 182 |
+
| Login loops on `/login` | Browser embedded in HF iframe blocks cookies. Open the Space in a new tab. |
|
| 183 |
+
| `Dashboard pages blank or 404 on refresh` | Should be fixed by the SPA rewriter in health-server.js. Hard-refresh and unregister service worker if cached: DevTools → Application → Service Workers → Unregister |
|
| 184 |
+
| Space sleeps after a few hours | Free tier limitation. Add `CLOUDFLARE_WORKERS_TOKEN` to provision a keep-alive cron worker |
|
| 185 |
+
| Telegram bot doesn't respond | HF Spaces blocks `api.telegram.org` egress. Add `CLOUDFLARE_WORKERS_TOKEN` to auto-provision an outbound proxy |
|
| 186 |
+
| Two Spaces overwriting each other's backup | Set different `BACKUP_DATASET_NAME` on each |
|
| 187 |
+
|
| 188 |
+
## Want a native Android app?
|
| 189 |
+
|
| 190 |
+
I have a companion Android wrapper at **[F4bC0d3/hermes-mobile](https://github.com/F4bC0d3/hermes-mobile)** — same auth flow, sessions drawer, ChatGPT/Claude-style top bar, all hermes-webui features inside. Just point it at your Space URL.
|
| 191 |
+
|
| 192 |
+
## Credits
|
| 193 |
+
|
| 194 |
+
- **[Nous Research](https://nousresearch.com)** for **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** — the agent runtime, the persistent memory system, the multi-provider LLM routing, the cron and skills systems. None of this exists without their work.
|
| 195 |
+
- **[@nesquena](https://github.com/nesquena)** for **[Hermes WebUI](https://github.com/nesquena/hermes-webui)** — the chat interface you actually see and use. Three-panel layout, SSE streaming, slash commands, profile management, theme system, mobile responsive design — all theirs.
|
| 196 |
+
- **[@somratpro](https://github.com/somratpro)** for **[HuggingMes](https://github.com/somratpro/HuggingMes)** — the HF Space packaging, the HF Dataset backup engine (`hermes-sync.py`), the Cloudflare proxy and keepalive setup, the Telegram integration, and the gateway auth wrapper. This repo is largely a fork of HuggingMes with WebUI added as the primary surface.
|
| 197 |
+
|
| 198 |
+
This repo's only contribution is the integration layer: a Node.js router that fronts both UIs on a single HF Space port, unified auth where one `GATEWAY_TOKEN` gates everything, and minor tweaks to `start.sh` to launch hermes-webui alongside the existing HuggingMes processes. If you find this useful, star the upstream projects, not this one.
|
| 199 |
+
|
| 200 |
+
## License
|
| 201 |
+
|
| 202 |
+
MIT — same as both upstream projects.
|
cloudflare-keepalive-setup.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""Create or reuse a Cloudflare Worker for Space keep-awake.
|
| 5 |
+
|
| 6 |
+
Vendored verbatim from github.com/somratpro/HuggingMes.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import sys
|
| 13 |
+
import time
|
| 14 |
+
import urllib.request
|
| 15 |
+
import urllib.error
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
API_BASE = "https://api.cloudflare.com/client/v4"
|
| 19 |
+
KEEPALIVE_STATUS_FILE = Path("/tmp/huggingmes-cloudflare-keepalive-status.json")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
|
| 23 |
+
req = urllib.request.Request(
|
| 24 |
+
f"{API_BASE}{path}",
|
| 25 |
+
data=body,
|
| 26 |
+
method=method,
|
| 27 |
+
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
| 28 |
+
)
|
| 29 |
+
try:
|
| 30 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 31 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 32 |
+
except urllib.error.HTTPError as e:
|
| 33 |
+
try:
|
| 34 |
+
error_body = json.loads(e.read().decode("utf-8"))
|
| 35 |
+
errors = error_body.get("errors") or [{"message": "Unknown error"}]
|
| 36 |
+
error_msg = errors[0].get("message", "Unknown error") if errors else "Unknown error"
|
| 37 |
+
except Exception:
|
| 38 |
+
error_msg = f"HTTP {e.code}: {e.reason}"
|
| 39 |
+
raise RuntimeError(f"Cloudflare API {e.code}: {error_msg}")
|
| 40 |
+
if not payload.get("success"):
|
| 41 |
+
errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
|
| 42 |
+
raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
|
| 43 |
+
return payload["result"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def slugify(value: str) -> str:
|
| 47 |
+
cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
| 48 |
+
cleaned = re.sub(r"-{2,}", "-", cleaned)
|
| 49 |
+
return (cleaned or "huggingmes-keepalive")[:63].rstrip("-")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_space_host() -> str:
|
| 53 |
+
space_host = os.environ.get("SPACE_HOST", "").strip()
|
| 54 |
+
if space_host:
|
| 55 |
+
return space_host
|
| 56 |
+
|
| 57 |
+
author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
| 58 |
+
repo = os.environ.get("SPACE_REPO_NAME", "").strip()
|
| 59 |
+
if author and repo:
|
| 60 |
+
return f"{author}-{repo}.hf.space".lower()
|
| 61 |
+
|
| 62 |
+
return ""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def derive_keepalive_worker_name() -> str:
|
| 66 |
+
explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
|
| 67 |
+
if explicit:
|
| 68 |
+
return slugify(explicit)
|
| 69 |
+
space_host = get_space_host()
|
| 70 |
+
if space_host:
|
| 71 |
+
return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
|
| 72 |
+
return "huggingmes-keepalive"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def render_keepalive_worker(target_url: str) -> str:
|
| 76 |
+
return f"""addEventListener("fetch", (event) => {{
|
| 77 |
+
event.respondWith(handleRequest(event.request));
|
| 78 |
+
}});
|
| 79 |
+
|
| 80 |
+
addEventListener("scheduled", (event) => {{
|
| 81 |
+
event.waitUntil(ping("cron"));
|
| 82 |
+
}});
|
| 83 |
+
|
| 84 |
+
const TARGET_URL = {json.dumps(target_url)};
|
| 85 |
+
|
| 86 |
+
async function ping(source) {{
|
| 87 |
+
const startedAt = new Date().toISOString();
|
| 88 |
+
try {{
|
| 89 |
+
const response = await fetch(TARGET_URL, {{
|
| 90 |
+
method: "GET",
|
| 91 |
+
headers: {{
|
| 92 |
+
"user-agent": "HuggingMes Cloudflare KeepAlive",
|
| 93 |
+
"cache-control": "no-cache"
|
| 94 |
+
}},
|
| 95 |
+
cf: {{ cacheTtl: 0, cacheEverything: false }}
|
| 96 |
+
}});
|
| 97 |
+
return {{
|
| 98 |
+
ok: response.ok,
|
| 99 |
+
status: response.status,
|
| 100 |
+
source,
|
| 101 |
+
target: TARGET_URL,
|
| 102 |
+
timestamp: startedAt
|
| 103 |
+
}};
|
| 104 |
+
}} catch (error) {{
|
| 105 |
+
return {{
|
| 106 |
+
ok: false,
|
| 107 |
+
status: 0,
|
| 108 |
+
source,
|
| 109 |
+
target: TARGET_URL,
|
| 110 |
+
timestamp: startedAt,
|
| 111 |
+
error: error.message
|
| 112 |
+
}};
|
| 113 |
+
}}
|
| 114 |
+
}}
|
| 115 |
+
|
| 116 |
+
async function handleRequest(request) {{
|
| 117 |
+
const url = new URL(request.url);
|
| 118 |
+
if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
|
| 119 |
+
const result = await ping("manual");
|
| 120 |
+
return new Response(JSON.stringify(result, null, 2), {{
|
| 121 |
+
status: result.ok ? 200 : 502,
|
| 122 |
+
headers: {{ "content-type": "application/json; charset=utf-8" }}
|
| 123 |
+
}});
|
| 124 |
+
}}
|
| 125 |
+
return new Response("Not found", {{ status: 404 }});
|
| 126 |
+
}}
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def write_keepalive_status(payload: dict) -> None:
|
| 131 |
+
payload = {
|
| 132 |
+
**payload,
|
| 133 |
+
"timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 134 |
+
}
|
| 135 |
+
KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
|
| 136 |
+
try:
|
| 137 |
+
KEEPALIVE_STATUS_FILE.chmod(0o600)
|
| 138 |
+
except OSError:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
|
| 143 |
+
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
|
| 144 |
+
if not account_id:
|
| 145 |
+
accounts = cf_request("GET", "/accounts", api_token)
|
| 146 |
+
if not accounts:
|
| 147 |
+
raise RuntimeError("No Cloudflare account is available for this token.")
|
| 148 |
+
account_id = accounts[0]["id"]
|
| 149 |
+
|
| 150 |
+
subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
|
| 151 |
+
subdomain = (subdomain_info or {}).get("subdomain", "").strip()
|
| 152 |
+
if not subdomain:
|
| 153 |
+
raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
|
| 154 |
+
return account_id, subdomain
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
|
| 158 |
+
enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
|
| 159 |
+
if enabled in {"0", "false", "no", "off"}:
|
| 160 |
+
write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
space_host = get_space_host()
|
| 164 |
+
if not space_host:
|
| 165 |
+
write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
|
| 169 |
+
space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
|
| 170 |
+
target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
|
| 171 |
+
worker_name = derive_keepalive_worker_name()
|
| 172 |
+
worker_source = render_keepalive_worker(target_url)
|
| 173 |
+
|
| 174 |
+
cf_request(
|
| 175 |
+
"PUT",
|
| 176 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}",
|
| 177 |
+
api_token,
|
| 178 |
+
body=worker_source.encode("utf-8"),
|
| 179 |
+
content_type="application/javascript",
|
| 180 |
+
)
|
| 181 |
+
cf_request(
|
| 182 |
+
"POST",
|
| 183 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
|
| 184 |
+
api_token,
|
| 185 |
+
body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
|
| 186 |
+
)
|
| 187 |
+
cf_request(
|
| 188 |
+
"PUT",
|
| 189 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
|
| 190 |
+
api_token,
|
| 191 |
+
body=json.dumps([{"cron": cron}]).encode("utf-8"),
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
|
| 195 |
+
write_keepalive_status(
|
| 196 |
+
{
|
| 197 |
+
"configured": True,
|
| 198 |
+
"status": "configured",
|
| 199 |
+
"workerName": worker_name,
|
| 200 |
+
"workerUrl": worker_url,
|
| 201 |
+
"targetUrl": target_url,
|
| 202 |
+
"cron": cron,
|
| 203 |
+
"message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def main() -> int:
|
| 209 |
+
api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
|
| 210 |
+
|
| 211 |
+
if not api_token:
|
| 212 |
+
return 0
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
account_id, subdomain = resolve_account_and_subdomain(api_token)
|
| 216 |
+
setup_keepalive_worker(api_token, account_id, subdomain)
|
| 217 |
+
return 0
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
|
| 220 |
+
write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
|
| 221 |
+
return 1
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
raise SystemExit(main())
|
cloudflare-proxy-setup.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""Create or reuse Cloudflare Workers for Telegram proxy and Space keep-awake.
|
| 5 |
+
|
| 6 |
+
Vendored verbatim from github.com/somratpro/HuggingMes.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import secrets
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
import urllib.request
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
API_BASE = "https://api.cloudflare.com/client/v4"
|
| 19 |
+
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 20 |
+
DEFAULT_ALLOWED = [
|
| 21 |
+
"api.telegram.org",
|
| 22 |
+
"discord.com",
|
| 23 |
+
"discordapp.com",
|
| 24 |
+
"gateway.discord.gg",
|
| 25 |
+
"status.discord.com",
|
| 26 |
+
"slack.com",
|
| 27 |
+
"api.slack.com",
|
| 28 |
+
"web.whatsapp.com",
|
| 29 |
+
"graph.facebook.com",
|
| 30 |
+
"graph.instagram.com",
|
| 31 |
+
"api.openai.com",
|
| 32 |
+
"googleapis.com",
|
| 33 |
+
"google.com",
|
| 34 |
+
"googleusercontent.com",
|
| 35 |
+
"gstatic.com",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
|
| 40 |
+
req = urllib.request.Request(
|
| 41 |
+
f"{API_BASE}{path}",
|
| 42 |
+
data=body,
|
| 43 |
+
method=method,
|
| 44 |
+
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
| 45 |
+
)
|
| 46 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 47 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 48 |
+
if not payload.get("success"):
|
| 49 |
+
errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
|
| 50 |
+
raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
|
| 51 |
+
return payload["result"]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def slugify(value: str) -> str:
|
| 55 |
+
cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
| 56 |
+
cleaned = re.sub(r"-{2,}", "-", cleaned)
|
| 57 |
+
return (cleaned or "huggingmes-proxy")[:63].rstrip("-")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def derive_worker_name() -> str:
|
| 61 |
+
explicit = os.environ.get("CLOUDFLARE_WORKER_NAME", "").strip()
|
| 62 |
+
if explicit:
|
| 63 |
+
return slugify(explicit)
|
| 64 |
+
space_host = os.environ.get("SPACE_HOST", "").strip()
|
| 65 |
+
if space_host:
|
| 66 |
+
return slugify(f"{space_host.replace('.hf.space', '')}-proxy")
|
| 67 |
+
return "huggingmes-proxy"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
|
| 71 |
+
return f"""addEventListener("fetch", (event) => {{
|
| 72 |
+
event.respondWith(handleRequest(event.request));
|
| 73 |
+
}});
|
| 74 |
+
|
| 75 |
+
const PROXY_SHARED_SECRET = {json.dumps(secret_value)};
|
| 76 |
+
const ALLOW_PROXY_ALL = {"true" if allow_proxy_all else "false"};
|
| 77 |
+
const ALLOWED_TARGETS = {json.dumps(allowed_targets)};
|
| 78 |
+
|
| 79 |
+
function isAllowedHost(hostname) {{
|
| 80 |
+
const normalized = String(hostname || "").trim().toLowerCase();
|
| 81 |
+
if (!normalized) return false;
|
| 82 |
+
if (ALLOW_PROXY_ALL) return true;
|
| 83 |
+
return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${{domain}}`));
|
| 84 |
+
}}
|
| 85 |
+
|
| 86 |
+
async function handleRequest(request) {{
|
| 87 |
+
const url = new URL(request.url);
|
| 88 |
+
const queryTarget = url.searchParams.get("proxy_target");
|
| 89 |
+
const targetHost = request.headers.get("x-target-host") || queryTarget;
|
| 90 |
+
|
| 91 |
+
if (PROXY_SHARED_SECRET) {{
|
| 92 |
+
const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
|
| 93 |
+
const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
|
| 94 |
+
if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {{
|
| 95 |
+
return new Response("Unauthorized: Invalid proxy key", {{ status: 401 }});
|
| 96 |
+
}}
|
| 97 |
+
}}
|
| 98 |
+
|
| 99 |
+
let targetBase = "";
|
| 100 |
+
if (targetHost) {{
|
| 101 |
+
if (!isAllowedHost(targetHost)) {{
|
| 102 |
+
return new Response(`Forbidden: Host ${{targetHost}} is not allowed.`, {{ status: 403 }});
|
| 103 |
+
}}
|
| 104 |
+
targetBase = `https://${{targetHost}}`;
|
| 105 |
+
}} else if (url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot")) {{
|
| 106 |
+
targetBase = "https://api.telegram.org";
|
| 107 |
+
}} else {{
|
| 108 |
+
return new Response("Invalid request: No target host provided.", {{ status: 400 }});
|
| 109 |
+
}}
|
| 110 |
+
|
| 111 |
+
const cleanSearch = new URLSearchParams(url.search);
|
| 112 |
+
cleanSearch.delete("proxy_target");
|
| 113 |
+
cleanSearch.delete("proxy_key");
|
| 114 |
+
const searchStr = cleanSearch.toString();
|
| 115 |
+
const targetUrl = targetBase + url.pathname + (searchStr ? `?${{searchStr}}` : "");
|
| 116 |
+
|
| 117 |
+
const headers = new Headers(request.headers);
|
| 118 |
+
for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {{
|
| 119 |
+
headers.delete(header);
|
| 120 |
+
}}
|
| 121 |
+
|
| 122 |
+
try {{
|
| 123 |
+
return await fetch(new Request(targetUrl, {{
|
| 124 |
+
method: request.method,
|
| 125 |
+
headers,
|
| 126 |
+
body: request.body,
|
| 127 |
+
redirect: "follow",
|
| 128 |
+
}}));
|
| 129 |
+
}} catch (error) {{
|
| 130 |
+
return new Response(`Proxy Error: ${{error.message}}`, {{ status: 502 }});
|
| 131 |
+
}}
|
| 132 |
+
}}
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def write_env(proxy_url: str, proxy_secret: str) -> None:
|
| 137 |
+
ENV_FILE.write_text(
|
| 138 |
+
f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
|
| 139 |
+
encoding="utf-8",
|
| 140 |
+
)
|
| 141 |
+
ENV_FILE.chmod(0o600)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
|
| 145 |
+
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
|
| 146 |
+
if not account_id:
|
| 147 |
+
accounts = cf_request("GET", "/accounts", api_token)
|
| 148 |
+
if not accounts:
|
| 149 |
+
raise RuntimeError("No Cloudflare account is available for this token.")
|
| 150 |
+
account_id = accounts[0]["id"]
|
| 151 |
+
|
| 152 |
+
subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
|
| 153 |
+
subdomain = (subdomain_info or {}).get("subdomain", "").strip()
|
| 154 |
+
if not subdomain:
|
| 155 |
+
raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
|
| 156 |
+
return account_id, subdomain
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def main() -> int:
|
| 160 |
+
existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
|
| 161 |
+
existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
|
| 162 |
+
api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
|
| 163 |
+
|
| 164 |
+
if existing_url:
|
| 165 |
+
write_env(existing_url, existing_secret)
|
| 166 |
+
|
| 167 |
+
if not api_token:
|
| 168 |
+
return 0
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
account_id, subdomain = resolve_account_and_subdomain(api_token)
|
| 172 |
+
|
| 173 |
+
if not existing_url:
|
| 174 |
+
allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
|
| 175 |
+
allow_proxy_all = allowed_raw == "*"
|
| 176 |
+
extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
|
| 177 |
+
allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
|
| 178 |
+
worker_name = derive_worker_name()
|
| 179 |
+
proxy_secret = existing_secret or secrets.token_urlsafe(24)
|
| 180 |
+
|
| 181 |
+
cf_request(
|
| 182 |
+
"PUT",
|
| 183 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}",
|
| 184 |
+
api_token,
|
| 185 |
+
body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
|
| 186 |
+
content_type="application/javascript",
|
| 187 |
+
)
|
| 188 |
+
cf_request(
|
| 189 |
+
"POST",
|
| 190 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
|
| 191 |
+
api_token,
|
| 192 |
+
body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
|
| 193 |
+
)
|
| 194 |
+
write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
|
| 195 |
+
|
| 196 |
+
return 0
|
| 197 |
+
except Exception as exc:
|
| 198 |
+
print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
|
| 199 |
+
return 1
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
if __name__ == "__main__":
|
| 203 |
+
raise SystemExit(main())
|
health-server.js
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* HuggingMes + Hermes WebUI — single-port router on HF Space port 7861.
|
| 5 |
+
*
|
| 6 |
+
* Routes:
|
| 7 |
+
* /login -> HuggingMes login (password = GATEWAY_TOKEN)
|
| 8 |
+
* /health /status -> JSON health (unauthenticated — for HF probes + keepalive)
|
| 9 |
+
* /hm /hm/* -> HuggingMes status page + app (auth-gated)
|
| 10 |
+
* /dashboard -> redirect to /hm
|
| 11 |
+
* /v1 /v1/* -> Hermes gateway (bearer auth; HTML => login redirect)
|
| 12 |
+
* /telegram /telegram/*-> Telegram webhook (unauthenticated; Telegram needs to reach it)
|
| 13 |
+
* everything else -> Hermes WebUI (nesquena/hermes-webui) as the primary UI
|
| 14 |
+
* WebUI handles its own login at /login-... no, wait: WebUI
|
| 15 |
+
* also exposes /login. We keep HuggingMes' login at /login
|
| 16 |
+
* so the shared GATEWAY_TOKEN gates both.
|
| 17 |
+
*
|
| 18 |
+
* Based on github.com/somratpro/HuggingMes with added WebUI routing as the
|
| 19 |
+
* primary UI.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
const http = require("http");
|
| 23 |
+
const fs = require("fs");
|
| 24 |
+
const net = require("net");
|
| 25 |
+
const crypto = require("crypto");
|
| 26 |
+
|
| 27 |
+
const PORT = Number(process.env.PORT || 7861);
|
| 28 |
+
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
|
| 29 |
+
const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
|
| 30 |
+
const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
|
| 31 |
+
const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787);
|
| 32 |
+
const GATEWAY_HOST = "127.0.0.1";
|
| 33 |
+
const startTime = Date.now();
|
| 34 |
+
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
|
| 35 |
+
const HM_PREFIX = "/hm";
|
| 36 |
+
const LOGIN_PATH = "/hm/login";
|
| 37 |
+
const SESSION_COOKIE = "huggingmes_session";
|
| 38 |
+
const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase();
|
| 39 |
+
|
| 40 |
+
const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
|
| 41 |
+
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
| 42 |
+
"/tmp/huggingmes-cloudflare-keepalive-status.json";
|
| 43 |
+
|
| 44 |
+
/* ── Port probing + auth ──────────────────────────────────────────── */
|
| 45 |
+
|
| 46 |
+
function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
|
| 47 |
+
return new Promise((resolve) => {
|
| 48 |
+
const socket = net.createConnection({ port, host });
|
| 49 |
+
const done = (ok) => {
|
| 50 |
+
socket.removeAllListeners();
|
| 51 |
+
socket.destroy();
|
| 52 |
+
resolve(ok);
|
| 53 |
+
};
|
| 54 |
+
socket.setTimeout(timeoutMs);
|
| 55 |
+
socket.once("connect", () => done(true));
|
| 56 |
+
socket.once("timeout", () => done(false));
|
| 57 |
+
socket.once("error", () => done(false));
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function readJson(path, fallback = null) {
|
| 62 |
+
try {
|
| 63 |
+
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
|
| 64 |
+
} catch {}
|
| 65 |
+
return fallback;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function timingSafeEqualString(left, right) {
|
| 69 |
+
if (!left || !right) return false;
|
| 70 |
+
const a = Buffer.from(left);
|
| 71 |
+
const b = Buffer.from(right);
|
| 72 |
+
if (a.length !== b.length) return false;
|
| 73 |
+
return crypto.timingSafeEqual(a, b);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function expectedSessionValue() {
|
| 77 |
+
if (!API_SERVER_KEY) return "";
|
| 78 |
+
return crypto
|
| 79 |
+
.createHmac("sha256", API_SERVER_KEY)
|
| 80 |
+
.update("huggingmes-session-v1")
|
| 81 |
+
.digest("hex");
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function parseCookies(req) {
|
| 85 |
+
const header = req.headers.cookie || "";
|
| 86 |
+
const cookies = {};
|
| 87 |
+
for (const item of header.split(";")) {
|
| 88 |
+
const sep = item.indexOf("=");
|
| 89 |
+
if (sep < 0) continue;
|
| 90 |
+
const name = item.slice(0, sep).trim();
|
| 91 |
+
const value = item.slice(sep + 1).trim();
|
| 92 |
+
if (!name) continue;
|
| 93 |
+
try {
|
| 94 |
+
cookies[name] = decodeURIComponent(value);
|
| 95 |
+
} catch {
|
| 96 |
+
cookies[name] = value;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
return cookies;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function isHttpsRequest(req) {
|
| 103 |
+
return req.headers["x-forwarded-proto"] === "https";
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function buildSessionCookie(req) {
|
| 107 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 108 |
+
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function getBearerToken(req) {
|
| 112 |
+
const value = req.headers.authorization || "";
|
| 113 |
+
const match = /^Bearer\s+(.+)$/i.exec(value);
|
| 114 |
+
return match ? match[1] : "";
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function isAuthorized(req) {
|
| 118 |
+
if (!API_SERVER_KEY) return true;
|
| 119 |
+
return (
|
| 120 |
+
timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
|
| 121 |
+
timingSafeEqualString(
|
| 122 |
+
parseCookies(req)[SESSION_COOKIE],
|
| 123 |
+
expectedSessionValue(),
|
| 124 |
+
)
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function sanitizeNext(value, fallback = "/") {
|
| 129 |
+
if (!value || typeof value !== "string") return fallback;
|
| 130 |
+
if (!value.startsWith("/") || value.startsWith("//")) return fallback;
|
| 131 |
+
return value;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function loginUrl(nextPath) {
|
| 135 |
+
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function wantsHtml(req) {
|
| 139 |
+
const accept = String(req.headers.accept || "");
|
| 140 |
+
return accept.includes("text/html");
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function escapeHtml(value) {
|
| 144 |
+
return String(value)
|
| 145 |
+
.replace(/&/g, "&")
|
| 146 |
+
.replace(/</g, "<")
|
| 147 |
+
.replace(/>/g, ">")
|
| 148 |
+
.replace(/"/g, """);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function readRequestBody(req, limit = 64 * 1024) {
|
| 152 |
+
return new Promise((resolve, reject) => {
|
| 153 |
+
let body = "";
|
| 154 |
+
req.on("data", (chunk) => {
|
| 155 |
+
body += chunk;
|
| 156 |
+
if (body.length > limit) {
|
| 157 |
+
reject(new Error("Request body is too large."));
|
| 158 |
+
req.destroy();
|
| 159 |
+
}
|
| 160 |
+
});
|
| 161 |
+
req.on("end", () => resolve(body));
|
| 162 |
+
req.on("error", reject);
|
| 163 |
+
});
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* ── Login page ───────────────────────────────────────────────────── */
|
| 167 |
+
|
| 168 |
+
function renderLoginPage(nextPath, errorMessage = "") {
|
| 169 |
+
const safeNext = sanitizeNext(nextPath, "/");
|
| 170 |
+
const errorHtml = errorMessage
|
| 171 |
+
? `<div class="error">${escapeHtml(errorMessage)}</div>`
|
| 172 |
+
: "";
|
| 173 |
+
return `<!doctype html>
|
| 174 |
+
<html lang="en">
|
| 175 |
+
<head>
|
| 176 |
+
<meta charset="utf-8" />
|
| 177 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 178 |
+
<title>HuggingMes + Hermes WebUI — Login</title>
|
| 179 |
+
<style>
|
| 180 |
+
:root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; }
|
| 181 |
+
* { box-sizing:border-box; }
|
| 182 |
+
body { margin:0; min-height:100vh; display:grid; place-items:center; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); padding:20px; }
|
| 183 |
+
main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; }
|
| 184 |
+
h1 { margin:0 0 8px; font-size:1.55rem; }
|
| 185 |
+
p { margin:0 0 22px; color:var(--muted); line-height:1.5; }
|
| 186 |
+
label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; }
|
| 187 |
+
input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; }
|
| 188 |
+
button { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:7px; color:#07111f; background:var(--accent); font:inherit; font-weight:750; cursor:pointer; }
|
| 189 |
+
.error { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#fecaca; border-radius:7px; padding:10px 12px; margin-bottom:16px; }
|
| 190 |
+
</style>
|
| 191 |
+
</head>
|
| 192 |
+
<body>
|
| 193 |
+
<main>
|
| 194 |
+
<h1>HuggingMes Admin</h1>
|
| 195 |
+
<p>Enter the <code>GATEWAY_TOKEN</code> from your Space secrets to access the status dashboard.<br>For the Hermes chat UI, go to <a href="/" style="color:var(--accent)">/</a>.</p>
|
| 196 |
+
${errorHtml}
|
| 197 |
+
<form method="post" action="${LOGIN_PATH}">
|
| 198 |
+
<input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
|
| 199 |
+
<label for="token">GATEWAY_TOKEN</label>
|
| 200 |
+
<input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
|
| 201 |
+
<button type="submit">Continue</button>
|
| 202 |
+
</form>
|
| 203 |
+
</main>
|
| 204 |
+
</body>
|
| 205 |
+
</html>`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
async function handleLogin(req, res, parsed) {
|
| 209 |
+
const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/");
|
| 210 |
+
|
| 211 |
+
if (!API_SERVER_KEY) {
|
| 212 |
+
redirect(res, nextPath);
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if (req.method === "GET") {
|
| 217 |
+
res.writeHead(200, {
|
| 218 |
+
"content-type": "text/html; charset=utf-8",
|
| 219 |
+
"cache-control": "no-store",
|
| 220 |
+
});
|
| 221 |
+
res.end(renderLoginPage(nextPath));
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
if (req.method !== "POST") {
|
| 226 |
+
res.writeHead(405, { allow: "GET, POST" });
|
| 227 |
+
res.end("Method not allowed");
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
try {
|
| 232 |
+
const body = await readRequestBody(req);
|
| 233 |
+
const params = new URLSearchParams(body);
|
| 234 |
+
const submittedToken = params.get("token") || "";
|
| 235 |
+
const submittedNext = sanitizeNext(params.get("next") || nextPath, "/");
|
| 236 |
+
|
| 237 |
+
if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
|
| 238 |
+
res.writeHead(401, {
|
| 239 |
+
"content-type": "text/html; charset=utf-8",
|
| 240 |
+
"cache-control": "no-store",
|
| 241 |
+
});
|
| 242 |
+
res.end(
|
| 243 |
+
renderLoginPage(
|
| 244 |
+
submittedNext,
|
| 245 |
+
"That token did not match GATEWAY_TOKEN.",
|
| 246 |
+
),
|
| 247 |
+
);
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
res.writeHead(302, {
|
| 252 |
+
location: submittedNext,
|
| 253 |
+
"set-cookie": buildSessionCookie(req),
|
| 254 |
+
"cache-control": "no-store",
|
| 255 |
+
});
|
| 256 |
+
res.end();
|
| 257 |
+
} catch (error) {
|
| 258 |
+
res.writeHead(400, {
|
| 259 |
+
"content-type": "text/plain; charset=utf-8",
|
| 260 |
+
"cache-control": "no-store",
|
| 261 |
+
});
|
| 262 |
+
res.end(error.message || "Invalid login request.");
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function requireAuth(req, res) {
|
| 267 |
+
if (isAuthorized(req)) return true;
|
| 268 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 269 |
+
redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
|
| 270 |
+
return false;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/* ── Upstream proxy ────────────────────────────────────────────────── */
|
| 274 |
+
|
| 275 |
+
function proxyRequest(
|
| 276 |
+
req,
|
| 277 |
+
res,
|
| 278 |
+
targetPort,
|
| 279 |
+
rewritePath = (path) => path,
|
| 280 |
+
headerOverrides = {},
|
| 281 |
+
) {
|
| 282 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 283 |
+
const targetPath = rewritePath(parsed.pathname) + parsed.search;
|
| 284 |
+
const headers = {
|
| 285 |
+
...req.headers,
|
| 286 |
+
...headerOverrides,
|
| 287 |
+
host: `${GATEWAY_HOST}:${targetPort}`,
|
| 288 |
+
"x-forwarded-host": req.headers.host || "",
|
| 289 |
+
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
const proxy = http.request(
|
| 293 |
+
{
|
| 294 |
+
hostname: GATEWAY_HOST,
|
| 295 |
+
port: targetPort,
|
| 296 |
+
method: req.method,
|
| 297 |
+
path: targetPath,
|
| 298 |
+
headers,
|
| 299 |
+
},
|
| 300 |
+
(upstream) => {
|
| 301 |
+
res.writeHead(upstream.statusCode || 502, upstream.headers);
|
| 302 |
+
upstream.pipe(res);
|
| 303 |
+
},
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
proxy.on("error", (error) => {
|
| 307 |
+
res.writeHead(502, { "content-type": "application/json" });
|
| 308 |
+
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
req.pipe(proxy);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function redirect(res, location, statusCode = 302) {
|
| 315 |
+
res.writeHead(statusCode, { location });
|
| 316 |
+
res.end();
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* ── Dashboard SPA proxy with HTML rewriting ──────────────────────────
|
| 320 |
+
*
|
| 321 |
+
* The Hermes dashboard is a Vite React app built for root-path deployment.
|
| 322 |
+
* Its HTML hardcodes window.__HERMES_BASE_PATH__="" and absolute src/href
|
| 323 |
+
* paths like /assets/index-XXX.js. Under /hm/app, React's router wouldn't
|
| 324 |
+
* know its basename and client-side routes (/config, /sessions, etc.) 404
|
| 325 |
+
* on refresh.
|
| 326 |
+
*
|
| 327 |
+
* This proxy:
|
| 328 |
+
* - serves the dashboard's index.html for any non-asset /hm/app/* path
|
| 329 |
+
* (SPA fallback, so /config, /profiles etc. work on direct load)
|
| 330 |
+
* - rewrites the returned HTML so React router uses /hm/app as its
|
| 331 |
+
* basename and absolute asset paths get prefixed with /hm/app
|
| 332 |
+
*/
|
| 333 |
+
function proxyDashboard(req, res) {
|
| 334 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 335 |
+
const inner = parsed.pathname.replace(`${HM_PREFIX}/app`, "") || "/";
|
| 336 |
+
|
| 337 |
+
const isAssetLike =
|
| 338 |
+
inner.startsWith("/assets/") ||
|
| 339 |
+
inner.startsWith("/api/") ||
|
| 340 |
+
inner.startsWith("/dashboard-plugins/") ||
|
| 341 |
+
inner.startsWith("/ds-assets/") ||
|
| 342 |
+
/\.[a-z0-9]{1,6}$/i.test(inner);
|
| 343 |
+
|
| 344 |
+
// SPA routes → serve index.html; everything else → forward as-is.
|
| 345 |
+
const targetPath =
|
| 346 |
+
(isAssetLike || inner === "/" ? inner : "/") + parsed.search;
|
| 347 |
+
|
| 348 |
+
const headers = {
|
| 349 |
+
...req.headers,
|
| 350 |
+
host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`,
|
| 351 |
+
"x-forwarded-host": req.headers.host || "",
|
| 352 |
+
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
|
| 353 |
+
// Disable upstream compression so we can rewrite text responses.
|
| 354 |
+
"accept-encoding": "identity",
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
const upstream = http.request(
|
| 358 |
+
{
|
| 359 |
+
hostname: GATEWAY_HOST,
|
| 360 |
+
port: DASHBOARD_PORT,
|
| 361 |
+
method: req.method,
|
| 362 |
+
path: targetPath,
|
| 363 |
+
headers,
|
| 364 |
+
},
|
| 365 |
+
(upRes) => {
|
| 366 |
+
const contentType = String(upRes.headers["content-type"] || "");
|
| 367 |
+
const shouldRewrite =
|
| 368 |
+
contentType.includes("text/html") ||
|
| 369 |
+
contentType.includes("application/xhtml");
|
| 370 |
+
|
| 371 |
+
if (!shouldRewrite) {
|
| 372 |
+
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
| 373 |
+
upRes.pipe(res);
|
| 374 |
+
return;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
const chunks = [];
|
| 378 |
+
upRes.on("data", (chunk) => chunks.push(chunk));
|
| 379 |
+
upRes.on("end", () => {
|
| 380 |
+
let body = Buffer.concat(chunks).toString("utf8");
|
| 381 |
+
|
| 382 |
+
// Tell the React router its basename.
|
| 383 |
+
body = body.replace(
|
| 384 |
+
/window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g,
|
| 385 |
+
`window.__HERMES_BASE_PATH__="${HM_PREFIX}/app"`,
|
| 386 |
+
);
|
| 387 |
+
|
| 388 |
+
// Prefix absolute asset URLs so they stay under /hm/app.
|
| 389 |
+
const prefix = `${HM_PREFIX}/app`;
|
| 390 |
+
body = body.replace(
|
| 391 |
+
/\b(src|href)="\/(?!\/|http)([^"]*)"/g,
|
| 392 |
+
(match, attr, rest) => {
|
| 393 |
+
if (
|
| 394 |
+
("/" + rest).startsWith(prefix + "/") ||
|
| 395 |
+
"/" + rest === prefix
|
| 396 |
+
) {
|
| 397 |
+
return match;
|
| 398 |
+
}
|
| 399 |
+
return `${attr}="${prefix}/${rest}"`;
|
| 400 |
+
},
|
| 401 |
+
);
|
| 402 |
+
|
| 403 |
+
const buf = Buffer.from(body, "utf8");
|
| 404 |
+
const outHeaders = { ...upRes.headers };
|
| 405 |
+
delete outHeaders["content-length"];
|
| 406 |
+
delete outHeaders["transfer-encoding"];
|
| 407 |
+
delete outHeaders["content-encoding"];
|
| 408 |
+
outHeaders["content-length"] = String(buf.length);
|
| 409 |
+
|
| 410 |
+
res.writeHead(upRes.statusCode || 502, outHeaders);
|
| 411 |
+
res.end(buf);
|
| 412 |
+
});
|
| 413 |
+
upRes.on("error", () => {
|
| 414 |
+
try {
|
| 415 |
+
res.writeHead(502);
|
| 416 |
+
res.end();
|
| 417 |
+
} catch {}
|
| 418 |
+
});
|
| 419 |
+
},
|
| 420 |
+
);
|
| 421 |
+
|
| 422 |
+
upstream.on("error", (error) => {
|
| 423 |
+
res.writeHead(502, { "content-type": "application/json" });
|
| 424 |
+
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
req.pipe(upstream);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* ── Status JSON + HuggingMes status page ─────────────────────────── */
|
| 431 |
+
|
| 432 |
+
function formatUptime(ms) {
|
| 433 |
+
const total = Math.floor(ms / 1000);
|
| 434 |
+
const days = Math.floor(total / 86400);
|
| 435 |
+
const hours = Math.floor((total % 86400) / 3600);
|
| 436 |
+
const minutes = Math.floor((total % 3600) / 60);
|
| 437 |
+
if (days) return `${days}d ${hours}h ${minutes}m`;
|
| 438 |
+
if (hours) return `${hours}h ${minutes}m`;
|
| 439 |
+
return `${minutes}m`;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
async function statusPayload() {
|
| 443 |
+
const gateway = await canConnect(GATEWAY_PORT);
|
| 444 |
+
const dashboard = await canConnect(DASHBOARD_PORT);
|
| 445 |
+
const webui = await canConnect(WEBUI_PORT);
|
| 446 |
+
const telegramWebhook =
|
| 447 |
+
!!process.env.TELEGRAM_WEBHOOK_URL &&
|
| 448 |
+
(await canConnect(TELEGRAM_WEBHOOK_PORT));
|
| 449 |
+
const sync = readJson(
|
| 450 |
+
SYNC_STATUS_FILE,
|
| 451 |
+
process.env.HF_TOKEN
|
| 452 |
+
? { status: "configured", message: "Backup enabled; waiting for first sync." }
|
| 453 |
+
: { status: "disabled", message: "HF_TOKEN is not configured." },
|
| 454 |
+
);
|
| 455 |
+
|
| 456 |
+
return {
|
| 457 |
+
ok: gateway && webui,
|
| 458 |
+
uptime: formatUptime(Date.now() - startTime),
|
| 459 |
+
startedAt: new Date(startTime).toISOString(),
|
| 460 |
+
gateway,
|
| 461 |
+
dashboard,
|
| 462 |
+
webui,
|
| 463 |
+
authConfigured: !!API_SERVER_KEY,
|
| 464 |
+
primaryUi: PRIMARY_UI,
|
| 465 |
+
ports: {
|
| 466 |
+
public: PORT,
|
| 467 |
+
gateway: GATEWAY_PORT,
|
| 468 |
+
dashboard: DASHBOARD_PORT,
|
| 469 |
+
webui: WEBUI_PORT,
|
| 470 |
+
telegramWebhook: TELEGRAM_WEBHOOK_PORT,
|
| 471 |
+
},
|
| 472 |
+
telegram: {
|
| 473 |
+
configured: !!process.env.TELEGRAM_BOT_TOKEN,
|
| 474 |
+
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
|
| 475 |
+
webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
|
| 476 |
+
webhookListening: telegramWebhook,
|
| 477 |
+
proxy: process.env.CLOUDFLARE_PROXY_URL || "",
|
| 478 |
+
},
|
| 479 |
+
model:
|
| 480 |
+
process.env.MODEL_FOR_CONFIG ||
|
| 481 |
+
process.env.HERMES_MODEL ||
|
| 482 |
+
process.env.LLM_MODEL ||
|
| 483 |
+
"",
|
| 484 |
+
provider:
|
| 485 |
+
process.env.PROVIDER_FOR_CONFIG ||
|
| 486 |
+
process.env.HERMES_INFERENCE_PROVIDER ||
|
| 487 |
+
"auto",
|
| 488 |
+
backup: sync,
|
| 489 |
+
keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
|
| 490 |
+
};
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function toneBadge(label, tone = "neutral") {
|
| 494 |
+
return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function valueOrUnset(value, fallback = "Not set") {
|
| 498 |
+
return value
|
| 499 |
+
? escapeHtml(value)
|
| 500 |
+
: `<span class="muted">${escapeHtml(fallback)}</span>`;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
|
| 504 |
+
return `<article class="tile ${tone}">
|
| 505 |
+
<div class="tile-head">
|
| 506 |
+
<span class="tile-title">${escapeHtml(title)}</span>
|
| 507 |
+
<span class="tile-dot"></span>
|
| 508 |
+
</div>
|
| 509 |
+
<div class="tile-value">${value}</div>
|
| 510 |
+
${detail ? `<div class="tile-detail">${detail}</div>` : ""}
|
| 511 |
+
${meta ? `<div class="tile-meta">${meta}</div>` : ""}
|
| 512 |
+
</article>`;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function renderStatusPage(data) {
|
| 516 |
+
const syncStatus = String(data.backup?.status || "unknown");
|
| 517 |
+
const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
|
| 518 |
+
? "ok"
|
| 519 |
+
: syncStatus === "disabled"
|
| 520 |
+
? "warn"
|
| 521 |
+
: "neutral";
|
| 522 |
+
const telegramTone = data.telegram.configured
|
| 523 |
+
? data.telegram.webhookListening || !data.telegram.webhook
|
| 524 |
+
? "ok"
|
| 525 |
+
: "warn"
|
| 526 |
+
: "warn";
|
| 527 |
+
const keepaliveConfigured = data.keepalive?.configured === true;
|
| 528 |
+
const keepaliveStatus = String(
|
| 529 |
+
data.keepalive?.status ||
|
| 530 |
+
(process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
|
| 531 |
+
);
|
| 532 |
+
const keepAliveTone = keepaliveConfigured
|
| 533 |
+
? "ok"
|
| 534 |
+
: process.env.CLOUDFLARE_WORKERS_TOKEN
|
| 535 |
+
? "warn"
|
| 536 |
+
: "neutral";
|
| 537 |
+
const telegramDetail = data.telegram.configured
|
| 538 |
+
? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}`
|
| 539 |
+
: "Not configured";
|
| 540 |
+
const backupDetail = data.backup?.message
|
| 541 |
+
? escapeHtml(data.backup.message)
|
| 542 |
+
: "No status yet";
|
| 543 |
+
const keepAliveDetail = keepaliveConfigured
|
| 544 |
+
? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
|
| 545 |
+
: keepaliveStatus === "error" && data.keepalive?.message
|
| 546 |
+
? escapeHtml(data.keepalive.message)
|
| 547 |
+
: process.env.CLOUDFLARE_WORKERS_TOKEN
|
| 548 |
+
? "Worker pending or failed"
|
| 549 |
+
: "Not configured";
|
| 550 |
+
|
| 551 |
+
const tiles = [
|
| 552 |
+
renderTile({
|
| 553 |
+
title: "WebUI",
|
| 554 |
+
value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"),
|
| 555 |
+
detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable",
|
| 556 |
+
tone: data.webui ? "ok" : "off",
|
| 557 |
+
}),
|
| 558 |
+
renderTile({
|
| 559 |
+
title: "Gateway",
|
| 560 |
+
value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
|
| 561 |
+
detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable",
|
| 562 |
+
tone: data.gateway ? "ok" : "off",
|
| 563 |
+
meta: data.authConfigured ? "Protected" : "Unprotected",
|
| 564 |
+
}),
|
| 565 |
+
renderTile({
|
| 566 |
+
title: "Model",
|
| 567 |
+
value: `<code>${valueOrUnset(data.model)}</code>`,
|
| 568 |
+
detail: `Provider: ${valueOrUnset(data.provider || "auto")}`,
|
| 569 |
+
tone: data.model ? "ok" : "warn",
|
| 570 |
+
}),
|
| 571 |
+
renderTile({
|
| 572 |
+
title: "Runtime",
|
| 573 |
+
value: escapeHtml(data.uptime),
|
| 574 |
+
detail: `Port ${data.ports.public}`,
|
| 575 |
+
tone: "neutral",
|
| 576 |
+
}),
|
| 577 |
+
renderTile({
|
| 578 |
+
title: "Telegram",
|
| 579 |
+
value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone),
|
| 580 |
+
detail: telegramDetail,
|
| 581 |
+
tone: telegramTone,
|
| 582 |
+
}),
|
| 583 |
+
renderTile({
|
| 584 |
+
title: "Backup",
|
| 585 |
+
value: toneBadge(syncStatus.toUpperCase(), syncTone),
|
| 586 |
+
detail: backupDetail,
|
| 587 |
+
tone: syncTone,
|
| 588 |
+
meta: data.backup?.timestamp
|
| 589 |
+
? `<span class="local-time" data-iso="${data.backup.timestamp}"></span>`
|
| 590 |
+
: "",
|
| 591 |
+
}),
|
| 592 |
+
renderTile({
|
| 593 |
+
title: "Keep Awake",
|
| 594 |
+
value: toneBadge(
|
| 595 |
+
keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
|
| 596 |
+
keepAliveTone,
|
| 597 |
+
),
|
| 598 |
+
detail: keepAliveDetail,
|
| 599 |
+
tone: keepAliveTone,
|
| 600 |
+
}),
|
| 601 |
+
].join("");
|
| 602 |
+
|
| 603 |
+
return `<!doctype html>
|
| 604 |
+
<html lang="en">
|
| 605 |
+
<head>
|
| 606 |
+
<meta charset="utf-8" />
|
| 607 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 608 |
+
<title>HuggingMes + Hermes WebUI</title>
|
| 609 |
+
<style>
|
| 610 |
+
:root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; }
|
| 611 |
+
* { box-sizing:border-box; }
|
| 612 |
+
body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
|
| 613 |
+
main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
|
| 614 |
+
header { text-align:center; margin-bottom:22px; }
|
| 615 |
+
h1 { margin:0; font-size:1.65rem; }
|
| 616 |
+
.subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
|
| 617 |
+
.row { display:flex; gap:10px; margin:24px 0 20px; flex-wrap:wrap; }
|
| 618 |
+
.hero-action { flex:1 1 200px; min-height:46px; display:flex; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; }
|
| 619 |
+
.hero-action.secondary { background:#232234; color:var(--text); border:1px solid var(--line); }
|
| 620 |
+
.hero-action:hover { opacity:.9; }
|
| 621 |
+
.overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
|
| 622 |
+
.tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
|
| 623 |
+
.tile.ok { border-color:rgba(34,197,94,.22); }
|
| 624 |
+
.tile.warn { border-color:rgba(245,197,66,.24); }
|
| 625 |
+
.tile.off { border-color:rgba(251,113,133,.28); }
|
| 626 |
+
.tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
| 627 |
+
.tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
|
| 628 |
+
.tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
|
| 629 |
+
.tile.ok .tile-dot { background:var(--good); }
|
| 630 |
+
.tile.warn .tile-dot { background:var(--warn); }
|
| 631 |
+
.tile.off .tile-dot { background:var(--bad); }
|
| 632 |
+
.tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
|
| 633 |
+
.tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
|
| 634 |
+
.tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
|
| 635 |
+
code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
|
| 636 |
+
.badge { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
|
| 637 |
+
.badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
|
| 638 |
+
.badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
|
| 639 |
+
.badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
|
| 640 |
+
.badge.neutral { color:var(--soft); }
|
| 641 |
+
.muted { color:var(--muted); }
|
| 642 |
+
footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
|
| 643 |
+
@media (max-width: 700px) { .overview { grid-template-columns:1fr; } }
|
| 644 |
+
</style>
|
| 645 |
+
</head>
|
| 646 |
+
<body>
|
| 647 |
+
<main>
|
| 648 |
+
<header>
|
| 649 |
+
<h1>HuggingMes + Hermes WebUI</h1>
|
| 650 |
+
<div class="subtitle">Self-hosted Hermes Agent on HF Spaces</div>
|
| 651 |
+
</header>
|
| 652 |
+
<div class="row">
|
| 653 |
+
<a class="hero-action" href="/" target="_blank" rel="noopener">Open Hermes WebUI -></a>
|
| 654 |
+
<a class="hero-action secondary" href="${HM_PREFIX}/app/" target="_blank" rel="noopener">Open Hermes Dashboard</a>
|
| 655 |
+
</div>
|
| 656 |
+
<section class="overview">
|
| 657 |
+
${tiles}
|
| 658 |
+
</section>
|
| 659 |
+
<footer>Built on <a href="https://github.com/somratpro/HuggingMes" style="color:var(--accent)">HuggingMes</a> + <a href="https://github.com/nesquena/hermes-webui" style="color:var(--accent)">Hermes WebUI</a></footer>
|
| 660 |
+
</main>
|
| 661 |
+
<script>
|
| 662 |
+
document.querySelectorAll('.local-time').forEach(el => {
|
| 663 |
+
const date = new Date(el.getAttribute('data-iso'));
|
| 664 |
+
if (!isNaN(date)) el.textContent = 'At ' + date.toLocaleTimeString();
|
| 665 |
+
});
|
| 666 |
+
</script>
|
| 667 |
+
</body>
|
| 668 |
+
</html>`;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
/* ── Server ───────────────────────────────────────────────────────── */
|
| 672 |
+
|
| 673 |
+
const server = http.createServer(async (req, res) => {
|
| 674 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 675 |
+
const path = parsed.pathname;
|
| 676 |
+
|
| 677 |
+
// 1. /hm/login — HuggingMes admin login (cookie-based, gates /hm/*).
|
| 678 |
+
// hermes-webui handles its own /login at the catch-all below.
|
| 679 |
+
if (path === LOGIN_PATH) {
|
| 680 |
+
await handleLogin(req, res, parsed);
|
| 681 |
+
return;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
// 2. /health — unauthenticated; HF Spaces probes + Cloudflare keepalive.
|
| 685 |
+
if (path === "/health") {
|
| 686 |
+
const data = await statusPayload();
|
| 687 |
+
res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
|
| 688 |
+
res.end(
|
| 689 |
+
JSON.stringify({
|
| 690 |
+
ok: data.ok,
|
| 691 |
+
gateway: data.gateway,
|
| 692 |
+
webui: data.webui,
|
| 693 |
+
uptime: data.uptime,
|
| 694 |
+
}),
|
| 695 |
+
);
|
| 696 |
+
return;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
// 3. /status — unauthenticated JSON status dump.
|
| 700 |
+
if (path === "/status" || path === "/api/status") {
|
| 701 |
+
const data = await statusPayload();
|
| 702 |
+
res.writeHead(200, { "content-type": "application/json" });
|
| 703 |
+
res.end(JSON.stringify(data, null, 2));
|
| 704 |
+
return;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
// 4. /telegram — webhook endpoint; no auth (Telegram can't do our cookie).
|
| 708 |
+
if (path === "/telegram" || path.startsWith("/telegram/")) {
|
| 709 |
+
proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
|
| 710 |
+
return;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// 5. /v1/* — Hermes gateway OpenAI-compatible API.
|
| 714 |
+
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 715 |
+
if (!isAuthorized(req)) {
|
| 716 |
+
if (wantsHtml(req)) {
|
| 717 |
+
redirect(res, loginUrl(`${path}${parsed.search}`));
|
| 718 |
+
return;
|
| 719 |
+
}
|
| 720 |
+
res.writeHead(401, {
|
| 721 |
+
"content-type": "application/json",
|
| 722 |
+
"cache-control": "no-store",
|
| 723 |
+
});
|
| 724 |
+
res.end(
|
| 725 |
+
JSON.stringify({
|
| 726 |
+
error: "unauthorized",
|
| 727 |
+
message: "Use Authorization: Bearer <GATEWAY_TOKEN>.",
|
| 728 |
+
}),
|
| 729 |
+
);
|
| 730 |
+
return;
|
| 731 |
+
}
|
| 732 |
+
const upstreamHeaders =
|
| 733 |
+
getBearerToken(req) || !API_SERVER_KEY
|
| 734 |
+
? {}
|
| 735 |
+
: { authorization: `Bearer ${API_SERVER_KEY}` };
|
| 736 |
+
proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders);
|
| 737 |
+
return;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
// 6. /hm — HuggingMes status page.
|
| 741 |
+
if (path === HM_PREFIX || path === `${HM_PREFIX}/`) {
|
| 742 |
+
if (!requireAuth(req, res)) return;
|
| 743 |
+
const data = await statusPayload();
|
| 744 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 745 |
+
res.end(renderStatusPage(data));
|
| 746 |
+
return;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
// /hm/app/* -> Hermes dashboard (SPA with HTML rewriting for base path)
|
| 750 |
+
if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
|
| 751 |
+
if (!requireAuth(req, res)) return;
|
| 752 |
+
proxyDashboard(req, res);
|
| 753 |
+
return;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
// /hm/status -> JSON
|
| 757 |
+
if (path === `${HM_PREFIX}/status`) {
|
| 758 |
+
if (!requireAuth(req, res)) return;
|
| 759 |
+
const data = await statusPayload();
|
| 760 |
+
res.writeHead(200, { "content-type": "application/json" });
|
| 761 |
+
res.end(JSON.stringify(data, null, 2));
|
| 762 |
+
return;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
// Legacy /dashboard -> /hm
|
| 766 |
+
if (path === "/dashboard" || path === "/dashboard/") {
|
| 767 |
+
redirect(res, `${HM_PREFIX}${parsed.search}`);
|
| 768 |
+
return;
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
// Root-path dashboard routes (config, env, providers, etc.) that users
|
| 772 |
+
// type or bookmark without the /hm/app prefix. Redirect them there.
|
| 773 |
+
const dashboardRootRoutes = new Set([
|
| 774 |
+
"/config",
|
| 775 |
+
"/env",
|
| 776 |
+
"/models",
|
| 777 |
+
"/providers",
|
| 778 |
+
"/profiles",
|
| 779 |
+
"/sessions",
|
| 780 |
+
"/skills",
|
| 781 |
+
"/cron",
|
| 782 |
+
"/analytics",
|
| 783 |
+
"/logs",
|
| 784 |
+
"/plugins",
|
| 785 |
+
"/chat",
|
| 786 |
+
"/docs",
|
| 787 |
+
]);
|
| 788 |
+
if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) {
|
| 789 |
+
redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`);
|
| 790 |
+
return;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
// 6b. Root-path requests whose Referer came from /hm/app/* must go to
|
| 794 |
+
// the dashboard, not WebUI. This covers:
|
| 795 |
+
// - Absolute assets (/assets/*, /ds-assets/*, /dashboard-plugins/*)
|
| 796 |
+
// - API calls (/api/*) when dashboard code uses absolute paths
|
| 797 |
+
// - Favicon (/favicon.ico)
|
| 798 |
+
// - WebSocket upgrades from dashboard pages
|
| 799 |
+
// - File downloads (any extensioned path referenced by dashboard)
|
| 800 |
+
// Both the Hermes dashboard AND hermes-webui use /api/* internally,
|
| 801 |
+
// so the Referer is the only reliable way to disambiguate.
|
| 802 |
+
const refererPath = (() => {
|
| 803 |
+
const ref = String(req.headers.referer || "");
|
| 804 |
+
if (!ref) return "";
|
| 805 |
+
try {
|
| 806 |
+
return new URL(ref).pathname;
|
| 807 |
+
} catch {
|
| 808 |
+
return "";
|
| 809 |
+
}
|
| 810 |
+
})();
|
| 811 |
+
const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
|
| 812 |
+
|
| 813 |
+
if (refererIsDashboard) {
|
| 814 |
+
// Anything with a Referer from the dashboard goes to the dashboard,
|
| 815 |
+
// *except* requests that explicitly start with /webui (escape hatch).
|
| 816 |
+
if (!path.startsWith("/webui")) {
|
| 817 |
+
if (!requireAuth(req, res)) return;
|
| 818 |
+
// Assets must NOT get the SPA fallback; pass them through as-is.
|
| 819 |
+
const parsed2 = new URL(req.url, "http://localhost");
|
| 820 |
+
const looksLikeAsset =
|
| 821 |
+
path.startsWith("/assets/") ||
|
| 822 |
+
path.startsWith("/ds-assets/") ||
|
| 823 |
+
path.startsWith("/dashboard-plugins/") ||
|
| 824 |
+
path.startsWith("/api/") ||
|
| 825 |
+
path === "/favicon.ico" ||
|
| 826 |
+
/\.[a-z0-9]{1,6}$/i.test(path);
|
| 827 |
+
if (looksLikeAsset) {
|
| 828 |
+
proxyRequest(req, res, DASHBOARD_PORT);
|
| 829 |
+
} else {
|
| 830 |
+
// Unlikely: a dashboard-referrer request for a non-asset, non-/hm
|
| 831 |
+
// path. Treat as a dashboard sub-route.
|
| 832 |
+
proxyDashboard(req, res);
|
| 833 |
+
}
|
| 834 |
+
return;
|
| 835 |
+
}
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
// 6c. /api/* routes — these are WebUI API calls when Referer isn't the
|
| 839 |
+
// dashboard. Fall through to the catch-all below.
|
| 840 |
+
|
| 841 |
+
// 7. Anything else -> Hermes WebUI (primary UI) OR HuggingMes status page.
|
| 842 |
+
// WebUI handles its own auth internally via HERMES_WEBUI_PASSWORD.
|
| 843 |
+
if (PRIMARY_UI === "dashboard" && path === "/") {
|
| 844 |
+
if (!requireAuth(req, res)) return;
|
| 845 |
+
const data = await statusPayload();
|
| 846 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 847 |
+
res.end(renderStatusPage(data));
|
| 848 |
+
return;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
// Catch-all -> WebUI. Don't gate at the router level: WebUI has its own
|
| 852 |
+
// password login. GATEWAY_TOKEN *is* the WebUI password (start.sh sets
|
| 853 |
+
// HERMES_WEBUI_PASSWORD=$GATEWAY_TOKEN).
|
| 854 |
+
proxyRequest(req, res, WEBUI_PORT);
|
| 855 |
+
});
|
| 856 |
+
|
| 857 |
+
server.listen(PORT, "0.0.0.0", () => {
|
| 858 |
+
console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`);
|
| 859 |
+
});
|
| 860 |
+
|
| 861 |
+
/* ── WebSocket upgrade handling ─────────────────────────────────────
|
| 862 |
+
*
|
| 863 |
+
* Both the Hermes dashboard and hermes-webui can open WebSocket
|
| 864 |
+
* connections for live updates. Route the upgrade to the correct
|
| 865 |
+
* upstream based on path prefix + referer, same as HTTP requests.
|
| 866 |
+
*/
|
| 867 |
+
server.on("upgrade", (req, clientSocket, head) => {
|
| 868 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 869 |
+
const path = parsed.pathname;
|
| 870 |
+
|
| 871 |
+
let targetPort = WEBUI_PORT;
|
| 872 |
+
let targetPath = req.url;
|
| 873 |
+
|
| 874 |
+
const refererPath = (() => {
|
| 875 |
+
const ref = String(req.headers.referer || "");
|
| 876 |
+
if (!ref) return "";
|
| 877 |
+
try {
|
| 878 |
+
return new URL(ref).pathname;
|
| 879 |
+
} catch {
|
| 880 |
+
return "";
|
| 881 |
+
}
|
| 882 |
+
})();
|
| 883 |
+
const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
|
| 884 |
+
|
| 885 |
+
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 886 |
+
targetPort = GATEWAY_PORT;
|
| 887 |
+
} else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
|
| 888 |
+
targetPort = DASHBOARD_PORT;
|
| 889 |
+
targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/";
|
| 890 |
+
if (parsed.search) targetPath += parsed.search;
|
| 891 |
+
} else if (refererIsDashboard && !path.startsWith("/webui")) {
|
| 892 |
+
targetPort = DASHBOARD_PORT;
|
| 893 |
+
} else if (path.startsWith("/webui/") || path === "/webui") {
|
| 894 |
+
targetPort = WEBUI_PORT;
|
| 895 |
+
targetPath = path.replace(/^\/webui/, "") || "/";
|
| 896 |
+
if (parsed.search) targetPath += parsed.search;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => {
|
| 900 |
+
// Forward the HTTP upgrade handshake verbatim
|
| 901 |
+
const headerLines = [
|
| 902 |
+
`${req.method} ${targetPath} HTTP/1.1`,
|
| 903 |
+
];
|
| 904 |
+
for (const [name, value] of Object.entries(req.headers)) {
|
| 905 |
+
if (Array.isArray(value)) {
|
| 906 |
+
for (const v of value) headerLines.push(`${name}: ${v}`);
|
| 907 |
+
} else {
|
| 908 |
+
headerLines.push(`${name}: ${value}`);
|
| 909 |
+
}
|
| 910 |
+
}
|
| 911 |
+
headerLines.push("", "");
|
| 912 |
+
upstream.write(headerLines.join("\r\n"));
|
| 913 |
+
if (head && head.length) upstream.write(head);
|
| 914 |
+
upstream.pipe(clientSocket);
|
| 915 |
+
clientSocket.pipe(upstream);
|
| 916 |
+
});
|
| 917 |
+
|
| 918 |
+
upstream.on("error", () => {
|
| 919 |
+
try {
|
| 920 |
+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
| 921 |
+
} catch {}
|
| 922 |
+
});
|
| 923 |
+
clientSocket.on("error", () => {
|
| 924 |
+
try {
|
| 925 |
+
upstream.destroy();
|
| 926 |
+
} catch {}
|
| 927 |
+
});
|
| 928 |
+
});
|
hermes-sync.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""HuggingMes Hermes state backup via Hugging Face Datasets.
|
| 3 |
+
|
| 4 |
+
Vendored verbatim from github.com/somratpro/HuggingMes.
|
| 5 |
+
Backs up HERMES_HOME (which includes /opt/data/webui — the hermes-webui state dir)
|
| 6 |
+
so sessions, profiles, skills, cron, memory, and workspace all survive restarts.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import shutil
|
| 14 |
+
import signal
|
| 15 |
+
import random
|
| 16 |
+
import socket
|
| 17 |
+
import sys
|
| 18 |
+
import tempfile
|
| 19 |
+
import threading
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
| 24 |
+
os.environ.setdefault("HF_HUB_VERBOSITY", "error")
|
| 25 |
+
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")
|
| 26 |
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
|
| 27 |
+
|
| 28 |
+
from huggingface_hub import HfApi, snapshot_download, upload_folder
|
| 29 |
+
from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
|
| 30 |
+
|
| 31 |
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
| 32 |
+
|
| 33 |
+
HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
| 34 |
+
STATUS_FILE = Path("/tmp/huggingmes-sync-status.json")
|
| 35 |
+
STATE_FILE = HERMES_HOME / ".huggingmes-sync-state.json"
|
| 36 |
+
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "600"))
|
| 37 |
+
INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
|
| 38 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 39 |
+
HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
|
| 40 |
+
SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
| 41 |
+
BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingmes-backup").strip()
|
| 42 |
+
INCLUDE_ENV = os.environ.get("SYNC_INCLUDE_ENV", "").strip().lower() in {"1", "true", "yes"}
|
| 43 |
+
MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
|
| 44 |
+
|
| 45 |
+
EXCLUDED_DIRS = {
|
| 46 |
+
".cache",
|
| 47 |
+
".git",
|
| 48 |
+
".npm",
|
| 49 |
+
".venv",
|
| 50 |
+
"__pycache__",
|
| 51 |
+
"node_modules",
|
| 52 |
+
"venv",
|
| 53 |
+
}
|
| 54 |
+
EXCLUDED_TOP_LEVEL = {"logs", STATE_FILE.name}
|
| 55 |
+
if not INCLUDE_ENV:
|
| 56 |
+
EXCLUDED_TOP_LEVEL.add(".env")
|
| 57 |
+
|
| 58 |
+
HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 59 |
+
STOP_EVENT = threading.Event()
|
| 60 |
+
_REPO_ID_CACHE: str | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def write_status(status: str, message: str, fingerprint: str | None = None, marker: tuple[int, int, int] | None = None) -> None:
|
| 64 |
+
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 65 |
+
payload = {"status": status, "message": message, "timestamp": timestamp}
|
| 66 |
+
|
| 67 |
+
tmp_path = STATUS_FILE.with_suffix(".tmp")
|
| 68 |
+
try:
|
| 69 |
+
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
|
| 70 |
+
tmp_path.replace(STATUS_FILE)
|
| 71 |
+
except OSError:
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
if fingerprint or marker:
|
| 75 |
+
state = {}
|
| 76 |
+
if STATE_FILE.exists():
|
| 77 |
+
try:
|
| 78 |
+
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
if fingerprint:
|
| 82 |
+
state["last_fingerprint"] = fingerprint
|
| 83 |
+
if marker:
|
| 84 |
+
state["last_marker"] = list(marker)
|
| 85 |
+
state["last_sync"] = timestamp
|
| 86 |
+
try:
|
| 87 |
+
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
|
| 88 |
+
except OSError:
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def resolve_backup_repo() -> str:
|
| 93 |
+
global _REPO_ID_CACHE
|
| 94 |
+
if _REPO_ID_CACHE:
|
| 95 |
+
return _REPO_ID_CACHE
|
| 96 |
+
|
| 97 |
+
namespace = HF_USERNAME or SPACE_AUTHOR_NAME
|
| 98 |
+
if not namespace and HF_API is not None:
|
| 99 |
+
whoami = HF_API.whoami()
|
| 100 |
+
namespace = whoami.get("name") or whoami.get("user") or ""
|
| 101 |
+
|
| 102 |
+
namespace = str(namespace).strip()
|
| 103 |
+
if not namespace:
|
| 104 |
+
raise RuntimeError("Could not determine HF username. Set HF_USERNAME or use an account HF_TOKEN.")
|
| 105 |
+
|
| 106 |
+
_REPO_ID_CACHE = f"{namespace}/{BACKUP_DATASET_NAME}"
|
| 107 |
+
return _REPO_ID_CACHE
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def ensure_repo_exists() -> str:
|
| 111 |
+
repo_id = resolve_backup_repo()
|
| 112 |
+
try:
|
| 113 |
+
HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
|
| 114 |
+
except RepositoryNotFoundError:
|
| 115 |
+
HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
|
| 116 |
+
return repo_id
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def should_exclude(rel_posix: str, path: Path) -> bool:
|
| 120 |
+
parts = Path(rel_posix).parts
|
| 121 |
+
if not parts:
|
| 122 |
+
return False
|
| 123 |
+
if parts[0] in EXCLUDED_TOP_LEVEL:
|
| 124 |
+
return True
|
| 125 |
+
if any(part in EXCLUDED_DIRS for part in parts):
|
| 126 |
+
return True
|
| 127 |
+
if path.is_file():
|
| 128 |
+
name_lower = path.name.lower()
|
| 129 |
+
if name_lower.endswith((".db-shm", ".db-wal", ".db-journal")):
|
| 130 |
+
return True
|
| 131 |
+
try:
|
| 132 |
+
return path.stat().st_size > MAX_FILE_SIZE_BYTES
|
| 133 |
+
except OSError:
|
| 134 |
+
return True
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 139 |
+
if not root.exists():
|
| 140 |
+
return (0, 0, 0)
|
| 141 |
+
file_count = 0
|
| 142 |
+
total_size = 0
|
| 143 |
+
newest_mtime = 0
|
| 144 |
+
for path in root.rglob("*"):
|
| 145 |
+
if not path.is_file():
|
| 146 |
+
continue
|
| 147 |
+
rel = path.relative_to(root).as_posix()
|
| 148 |
+
if should_exclude(rel, path):
|
| 149 |
+
continue
|
| 150 |
+
try:
|
| 151 |
+
stat = path.stat()
|
| 152 |
+
except OSError:
|
| 153 |
+
continue
|
| 154 |
+
file_count += 1
|
| 155 |
+
total_size += int(stat.st_size)
|
| 156 |
+
newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
|
| 157 |
+
return (file_count, total_size, newest_mtime)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def fingerprint_dir(root: Path) -> str:
|
| 161 |
+
hasher = hashlib.sha256()
|
| 162 |
+
if not root.exists():
|
| 163 |
+
return hasher.hexdigest()
|
| 164 |
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
| 165 |
+
rel = path.relative_to(root).as_posix()
|
| 166 |
+
if should_exclude(rel, path):
|
| 167 |
+
continue
|
| 168 |
+
hasher.update(rel.encode("utf-8"))
|
| 169 |
+
with path.open("rb") as handle:
|
| 170 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 171 |
+
hasher.update(chunk)
|
| 172 |
+
return hasher.hexdigest()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def create_snapshot_dir(source_root: Path) -> Path:
|
| 176 |
+
staging_root = Path(tempfile.mkdtemp(prefix="huggingmes-sync-"))
|
| 177 |
+
for path in sorted(source_root.rglob("*")):
|
| 178 |
+
rel = path.relative_to(source_root)
|
| 179 |
+
rel_posix = rel.as_posix()
|
| 180 |
+
if should_exclude(rel_posix, path):
|
| 181 |
+
continue
|
| 182 |
+
target = staging_root / rel
|
| 183 |
+
if path.is_dir():
|
| 184 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
continue
|
| 186 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
try:
|
| 188 |
+
shutil.copy2(path, target)
|
| 189 |
+
except OSError:
|
| 190 |
+
continue
|
| 191 |
+
return staging_root
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def restore() -> bool:
|
| 195 |
+
if not HF_TOKEN:
|
| 196 |
+
write_status("disabled", "HF_TOKEN is not configured.")
|
| 197 |
+
return False
|
| 198 |
+
|
| 199 |
+
repo_id = resolve_backup_repo()
|
| 200 |
+
write_status("restoring", f"Restoring Hermes state from {repo_id}")
|
| 201 |
+
try:
|
| 202 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 203 |
+
snapshot_download(repo_id=repo_id, repo_type="dataset", token=HF_TOKEN, local_dir=tmpdir)
|
| 204 |
+
tmp_path = Path(tmpdir)
|
| 205 |
+
if not any(tmp_path.iterdir()):
|
| 206 |
+
write_status("fresh", "Backup dataset is empty. Starting fresh.")
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
HERMES_HOME.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
for child in tmp_path.iterdir():
|
| 211 |
+
if should_exclude(child.name, child):
|
| 212 |
+
continue
|
| 213 |
+
target = HERMES_HOME / child.name
|
| 214 |
+
if target.is_dir():
|
| 215 |
+
shutil.rmtree(target, ignore_errors=True)
|
| 216 |
+
elif target.exists():
|
| 217 |
+
target.unlink()
|
| 218 |
+
if child.is_dir():
|
| 219 |
+
shutil.copytree(child, target)
|
| 220 |
+
else:
|
| 221 |
+
shutil.copy2(child, target)
|
| 222 |
+
|
| 223 |
+
write_status("restored", f"Restored Hermes state from {repo_id}")
|
| 224 |
+
return True
|
| 225 |
+
except RepositoryNotFoundError:
|
| 226 |
+
write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
|
| 227 |
+
return True
|
| 228 |
+
except HfHubHTTPError as exc:
|
| 229 |
+
if exc.response is not None and exc.response.status_code == 404:
|
| 230 |
+
write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
|
| 231 |
+
return True
|
| 232 |
+
write_status("error", f"Restore failed: {exc}")
|
| 233 |
+
print(f"Restore failed: {exc}", file=sys.stderr)
|
| 234 |
+
return False
|
| 235 |
+
except Exception as exc:
|
| 236 |
+
write_status("error", f"Restore failed: {exc}")
|
| 237 |
+
print(f"Restore failed: {exc}", file=sys.stderr)
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def sync_once(last_fingerprint: str | None = None, last_marker: tuple[int, int, int] | None = None):
|
| 242 |
+
if last_fingerprint is None and last_marker is None:
|
| 243 |
+
if STATE_FILE.exists():
|
| 244 |
+
try:
|
| 245 |
+
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
| 246 |
+
last_fingerprint = state.get("last_fingerprint")
|
| 247 |
+
m = state.get("last_marker")
|
| 248 |
+
if m and len(m) == 3:
|
| 249 |
+
last_marker = tuple(m)
|
| 250 |
+
except Exception:
|
| 251 |
+
pass
|
| 252 |
+
|
| 253 |
+
repo_id = ensure_repo_exists()
|
| 254 |
+
current_marker = metadata_marker(HERMES_HOME)
|
| 255 |
+
if last_marker is not None and current_marker == last_marker:
|
| 256 |
+
write_status("synced", "No Hermes state changes detected (marker match).")
|
| 257 |
+
return (last_fingerprint or "", current_marker)
|
| 258 |
+
|
| 259 |
+
current_fingerprint = fingerprint_dir(HERMES_HOME)
|
| 260 |
+
if last_fingerprint is not None and current_fingerprint == last_fingerprint:
|
| 261 |
+
write_status("synced", "No Hermes state changes detected (fingerprint match).")
|
| 262 |
+
return (last_fingerprint, current_marker)
|
| 263 |
+
|
| 264 |
+
hostname = socket.gethostname()
|
| 265 |
+
write_status("syncing", f"Uploading Hermes state to {repo_id} from {hostname}")
|
| 266 |
+
snapshot_dir = create_snapshot_dir(HERMES_HOME)
|
| 267 |
+
try:
|
| 268 |
+
upload_folder(
|
| 269 |
+
folder_path=str(snapshot_dir),
|
| 270 |
+
repo_id=repo_id,
|
| 271 |
+
repo_type="dataset",
|
| 272 |
+
token=HF_TOKEN,
|
| 273 |
+
commit_message=f"HuggingMes sync [{hostname}] {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 274 |
+
ignore_patterns=[".git/*", ".git"],
|
| 275 |
+
)
|
| 276 |
+
finally:
|
| 277 |
+
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 278 |
+
|
| 279 |
+
write_status("success", f"Uploaded Hermes state to {repo_id}", fingerprint=current_fingerprint, marker=current_marker)
|
| 280 |
+
return (current_fingerprint, current_marker)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def handle_signal(_sig, _frame) -> None:
|
| 284 |
+
STOP_EVENT.set()
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def loop() -> int:
|
| 288 |
+
signal.signal(signal.SIGTERM, handle_signal)
|
| 289 |
+
signal.signal(signal.SIGINT, handle_signal)
|
| 290 |
+
try:
|
| 291 |
+
repo_id = resolve_backup_repo()
|
| 292 |
+
write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
|
| 293 |
+
except Exception as exc:
|
| 294 |
+
write_status("error", str(exc))
|
| 295 |
+
print(f"Hermes sync error: {exc}")
|
| 296 |
+
return 1
|
| 297 |
+
|
| 298 |
+
last_fingerprint = fingerprint_dir(HERMES_HOME)
|
| 299 |
+
last_marker = metadata_marker(HERMES_HOME)
|
| 300 |
+
time.sleep(INITIAL_DELAY)
|
| 301 |
+
print(f"Hermes state sync started: every {INTERVAL}s -> {repo_id}")
|
| 302 |
+
|
| 303 |
+
while not STOP_EVENT.is_set():
|
| 304 |
+
try:
|
| 305 |
+
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
| 306 |
+
except Exception as exc:
|
| 307 |
+
write_status("error", f"Sync failed: {exc}")
|
| 308 |
+
print(f"Hermes sync failed: {exc}")
|
| 309 |
+
jitter = random.uniform(0.9, 1.1)
|
| 310 |
+
if STOP_EVENT.wait(INTERVAL * jitter):
|
| 311 |
+
break
|
| 312 |
+
return 0
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def main() -> int:
|
| 316 |
+
HERMES_HOME.mkdir(parents=True, exist_ok=True)
|
| 317 |
+
if len(sys.argv) < 2:
|
| 318 |
+
return loop()
|
| 319 |
+
command = sys.argv[1]
|
| 320 |
+
if command == "restore":
|
| 321 |
+
return 0 if restore() else 1
|
| 322 |
+
if command == "sync-once":
|
| 323 |
+
try:
|
| 324 |
+
sync_once()
|
| 325 |
+
return 0
|
| 326 |
+
except Exception as exc:
|
| 327 |
+
write_status("error", f"Shutdown sync failed: {exc}")
|
| 328 |
+
print(f"Hermes sync: shutdown sync failed: {exc}")
|
| 329 |
+
return 1
|
| 330 |
+
if command == "loop":
|
| 331 |
+
return loop()
|
| 332 |
+
print(f"Unknown command: {command}", file=sys.stderr)
|
| 333 |
+
return 1
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
if __name__ == "__main__":
|
| 337 |
+
raise SystemExit(main())
|
start.sh
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
umask 0077
|
| 5 |
+
|
| 6 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 7 |
+
# HuggingMes + Hermes WebUI — integrated Hermes Agent stack for HF Spaces
|
| 8 |
+
# Based on github.com/somratpro/HuggingMes, with Hermes WebUI
|
| 9 |
+
# (github.com/nesquena/hermes-webui) as the primary UI.
|
| 10 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 11 |
+
|
| 12 |
+
APP_DIR="${HUGGINGMES_APP_DIR:-/opt/huggingmes}"
|
| 13 |
+
WEBUI_REPO="${HERMES_WEBUI_REPO:-/opt/hermes-webui}"
|
| 14 |
+
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
| 15 |
+
|
| 16 |
+
PUBLIC_PORT="${PORT:-7861}"
|
| 17 |
+
GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"
|
| 18 |
+
DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"
|
| 19 |
+
TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
|
| 20 |
+
WEBUI_PORT="${HERMES_WEBUI_PORT:-8787}"
|
| 21 |
+
|
| 22 |
+
SYNC_INTERVAL="${SYNC_INTERVAL:-600}"
|
| 23 |
+
BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmes-backup}"
|
| 24 |
+
CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"
|
| 25 |
+
|
| 26 |
+
export HERMES_HOME
|
| 27 |
+
export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
|
| 28 |
+
export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"
|
| 29 |
+
export API_SERVER_PORT="$GATEWAY_API_PORT"
|
| 30 |
+
export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"
|
| 31 |
+
export TELEGRAM_WEBHOOK_PORT
|
| 32 |
+
export HERMES_WEBUI_PORT="$WEBUI_PORT"
|
| 33 |
+
|
| 34 |
+
echo ""
|
| 35 |
+
echo " ╔══════════════════════════════════════════╗"
|
| 36 |
+
echo " ║ 🪽 HuggingMes + Hermes WebUI Gateway ║"
|
| 37 |
+
echo " ╚══════════════════════════════════════════╝"
|
| 38 |
+
echo ""
|
| 39 |
+
|
| 40 |
+
# ── Unified auth: GATEWAY_TOKEN drives everything ─────────────────────
|
| 41 |
+
if [ -z "${API_SERVER_KEY:-}" ]; then
|
| 42 |
+
if [ -n "${GATEWAY_TOKEN:-}" ]; then
|
| 43 |
+
export API_SERVER_KEY="$GATEWAY_TOKEN"
|
| 44 |
+
else
|
| 45 |
+
API_SERVER_KEY="$(python3 - <<'PY'
|
| 46 |
+
import secrets
|
| 47 |
+
print(secrets.token_urlsafe(32))
|
| 48 |
+
PY
|
| 49 |
+
)"
|
| 50 |
+
export API_SERVER_KEY
|
| 51 |
+
echo "GATEWAY_TOKEN not set - generated an ephemeral token for this boot."
|
| 52 |
+
fi
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
# Same token becomes Hermes WebUI's login password (unified auth).
|
| 56 |
+
if [ -n "${GATEWAY_TOKEN:-}" ]; then
|
| 57 |
+
export HERMES_WEBUI_PASSWORD="${HERMES_WEBUI_PASSWORD:-$GATEWAY_TOKEN}"
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
# ── Setup state dirs ──────────────────────────────────────────────────
|
| 61 |
+
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home,plugins,webui}
|
| 62 |
+
|
| 63 |
+
# Expose hermes CLI to login shells
|
| 64 |
+
mkdir -p "$HERMES_HOME/.local/bin"
|
| 65 |
+
ln -sfn /opt/hermes/.venv/bin/hermes "$HERMES_HOME/.local/bin/hermes"
|
| 66 |
+
|
| 67 |
+
# Redirect Hermes plugin dir into volume
|
| 68 |
+
if [ ! -L "${HOME}/.hermes/plugins" ]; then
|
| 69 |
+
mkdir -p "${HOME}/.hermes"
|
| 70 |
+
rm -rf "${HOME}/.hermes/plugins"
|
| 71 |
+
ln -sfn "$HERMES_HOME/plugins" "${HOME}/.hermes/plugins"
|
| 72 |
+
fi
|
| 73 |
+
|
| 74 |
+
# ── Restore state from HF Dataset ─────────────────────────────────────
|
| 75 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 76 |
+
echo "Restoring Hermes state from HF Dataset..."
|
| 77 |
+
python3 "$APP_DIR/hermes-sync.py" restore || true
|
| 78 |
+
else
|
| 79 |
+
echo "HF_TOKEN not set - dataset persistence is disabled."
|
| 80 |
+
fi
|
| 81 |
+
|
| 82 |
+
# ── Cloudflare proxy (optional) ───────────────────────────────────────
|
| 83 |
+
CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
|
| 84 |
+
export CLOUDFLARE_WORKERS_TOKEN
|
| 85 |
+
if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 86 |
+
export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
|
| 87 |
+
echo "Preparing Cloudflare Telegram proxy..."
|
| 88 |
+
python3 "$APP_DIR/cloudflare-proxy-setup.py" || true
|
| 89 |
+
if [ -f "$CF_PROXY_ENV_FILE" ]; then
|
| 90 |
+
. "$CF_PROXY_ENV_FILE"
|
| 91 |
+
fi
|
| 92 |
+
fi
|
| 93 |
+
|
| 94 |
+
if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
|
| 95 |
+
echo "Preparing Cloudflare Keepalive worker..."
|
| 96 |
+
python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true
|
| 97 |
+
fi
|
| 98 |
+
|
| 99 |
+
# ── Telegram env normalisation (aliases + webhook URL + secret) ───────
|
| 100 |
+
if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 101 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
|
| 102 |
+
elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 103 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
|
| 104 |
+
fi
|
| 105 |
+
|
| 106 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
|
| 107 |
+
if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
|
| 108 |
+
export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
|
| 109 |
+
fi
|
| 110 |
+
fi
|
| 111 |
+
|
| 112 |
+
if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
|
| 113 |
+
SECRET_FILE="$HERMES_HOME/.huggingmes-telegram-webhook-secret"
|
| 114 |
+
if [ -f "$SECRET_FILE" ]; then
|
| 115 |
+
TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
|
| 116 |
+
else
|
| 117 |
+
TELEGRAM_WEBHOOK_SECRET="$(python3 - <<'PY'
|
| 118 |
+
import secrets
|
| 119 |
+
print(secrets.token_hex(32))
|
| 120 |
+
PY
|
| 121 |
+
)"
|
| 122 |
+
printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
|
| 123 |
+
chmod 600 "$SECRET_FILE"
|
| 124 |
+
fi
|
| 125 |
+
export TELEGRAM_WEBHOOK_SECRET
|
| 126 |
+
fi
|
| 127 |
+
|
| 128 |
+
# ── Provider-prefix mapping (HuggingMes convention) ───────────────────
|
| 129 |
+
MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
|
| 130 |
+
MODEL_FOR_CONFIG="$MODEL_INPUT"
|
| 131 |
+
PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
|
| 132 |
+
LLM_API_KEY="${LLM_API_KEY:-}"
|
| 133 |
+
|
| 134 |
+
if [ -n "$MODEL_INPUT" ]; then
|
| 135 |
+
MODEL_PREFIX="${MODEL_INPUT%%/*}"
|
| 136 |
+
else
|
| 137 |
+
MODEL_PREFIX=""
|
| 138 |
+
fi
|
| 139 |
+
|
| 140 |
+
case "$MODEL_PREFIX" in
|
| 141 |
+
openrouter)
|
| 142 |
+
[ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
|
| 143 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
|
| 144 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
|
| 145 |
+
;;
|
| 146 |
+
huggingface|hf)
|
| 147 |
+
[ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
|
| 148 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
|
| 149 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
|
| 150 |
+
;;
|
| 151 |
+
vercel-ai-gateway|ai-gateway)
|
| 152 |
+
[ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
|
| 153 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
|
| 154 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
|
| 155 |
+
;;
|
| 156 |
+
anthropic)
|
| 157 |
+
[ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
|
| 158 |
+
;;
|
| 159 |
+
openai|openai-codex)
|
| 160 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 161 |
+
;;
|
| 162 |
+
google|gemini)
|
| 163 |
+
[ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
|
| 164 |
+
PROVIDER_FOR_CONFIG="gemini"
|
| 165 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
|
| 166 |
+
;;
|
| 167 |
+
deepseek)
|
| 168 |
+
[ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
|
| 169 |
+
;;
|
| 170 |
+
kimi-coding|moonshot)
|
| 171 |
+
[ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
|
| 172 |
+
;;
|
| 173 |
+
kimi-coding-cn|moonshot-cn|kimi-cn)
|
| 174 |
+
[ -n "$LLM_API_KEY" ] && export KIMI_CN_API_KEY="${KIMI_CN_API_KEY:-$LLM_API_KEY}"
|
| 175 |
+
;;
|
| 176 |
+
minimax)
|
| 177 |
+
[ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
|
| 178 |
+
;;
|
| 179 |
+
minimax-cn)
|
| 180 |
+
[ -n "$LLM_API_KEY" ] && export MINIMAX_CN_API_KEY="${MINIMAX_CN_API_KEY:-$LLM_API_KEY}"
|
| 181 |
+
;;
|
| 182 |
+
xiaomi)
|
| 183 |
+
[ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
|
| 184 |
+
;;
|
| 185 |
+
zai|z-ai|z.ai|glm)
|
| 186 |
+
[ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
|
| 187 |
+
;;
|
| 188 |
+
arcee|arcee-ai|arceeai)
|
| 189 |
+
[ -n "$LLM_API_KEY" ] && export ARCEEAI_API_KEY="${ARCEEAI_API_KEY:-$LLM_API_KEY}"
|
| 190 |
+
;;
|
| 191 |
+
gmi|gmi-cloud|gmicloud)
|
| 192 |
+
[ -n "$LLM_API_KEY" ] && export GMI_API_KEY="${GMI_API_KEY:-$LLM_API_KEY}"
|
| 193 |
+
;;
|
| 194 |
+
alibaba|alibaba-coding-plan|alibaba_coding)
|
| 195 |
+
[ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"
|
| 196 |
+
;;
|
| 197 |
+
tencent-tokenhub|tencent|tokenhub|tencentmaas)
|
| 198 |
+
[ -n "$LLM_API_KEY" ] && export TOKENHUB_API_KEY="${TOKENHUB_API_KEY:-$LLM_API_KEY}"
|
| 199 |
+
;;
|
| 200 |
+
nvidia)
|
| 201 |
+
[ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
|
| 202 |
+
;;
|
| 203 |
+
xai|grok)
|
| 204 |
+
[ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
|
| 205 |
+
;;
|
| 206 |
+
kilocode)
|
| 207 |
+
[ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
|
| 208 |
+
;;
|
| 209 |
+
opencode-zen)
|
| 210 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
|
| 211 |
+
;;
|
| 212 |
+
opencode-go)
|
| 213 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
|
| 214 |
+
;;
|
| 215 |
+
esac
|
| 216 |
+
|
| 217 |
+
if [ -n "${CUSTOM_BASE_URL:-}" ]; then
|
| 218 |
+
PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
|
| 219 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 220 |
+
fi
|
| 221 |
+
|
| 222 |
+
export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
|
| 223 |
+
export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
|
| 224 |
+
export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
|
| 225 |
+
export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
|
| 226 |
+
export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
|
| 227 |
+
export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
|
| 228 |
+
export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
|
| 229 |
+
|
| 230 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
|
| 231 |
+
CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
|
| 232 |
+
export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
|
| 233 |
+
export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
|
| 234 |
+
fi
|
| 235 |
+
|
| 236 |
+
# ── Build Hermes config.yaml ──────────────────────────────────────────
|
| 237 |
+
python3 - <<'PY'
|
| 238 |
+
import os
|
| 239 |
+
from pathlib import Path
|
| 240 |
+
import yaml
|
| 241 |
+
|
| 242 |
+
home = Path(os.environ["HERMES_HOME"])
|
| 243 |
+
path = home / "config.yaml"
|
| 244 |
+
try:
|
| 245 |
+
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
| 246 |
+
except FileNotFoundError:
|
| 247 |
+
config = {}
|
| 248 |
+
|
| 249 |
+
model_name = os.environ.get("MODEL_FOR_CONFIG", "").strip()
|
| 250 |
+
provider_name = os.environ.get("PROVIDER_FOR_CONFIG", "").strip()
|
| 251 |
+
|
| 252 |
+
if model_name:
|
| 253 |
+
model = config.setdefault("model", {})
|
| 254 |
+
model["default"] = model_name
|
| 255 |
+
if provider_name and provider_name != "auto":
|
| 256 |
+
model["provider"] = provider_name
|
| 257 |
+
else:
|
| 258 |
+
model.pop("provider", None)
|
| 259 |
+
else:
|
| 260 |
+
model = config.get("model", {})
|
| 261 |
+
print("No LLM_MODEL/HERMES_MODEL set; leaving Hermes model config unchanged.")
|
| 262 |
+
|
| 263 |
+
custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
|
| 264 |
+
if custom_base and model_name:
|
| 265 |
+
model.setdefault("base_url", custom_base.rstrip("/"))
|
| 266 |
+
if os.environ.get("CUSTOM_API_KEY"):
|
| 267 |
+
model.setdefault("api_key", os.environ["CUSTOM_API_KEY"])
|
| 268 |
+
try:
|
| 269 |
+
model.setdefault("context_length", int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072")))
|
| 270 |
+
model.setdefault("max_tokens", int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192")))
|
| 271 |
+
except ValueError:
|
| 272 |
+
pass
|
| 273 |
+
|
| 274 |
+
config.setdefault("terminal", {}).setdefault("cwd", os.environ.get("MESSAGING_CWD", str(home / "workspace")))
|
| 275 |
+
config.setdefault("compression", {}).setdefault("enabled", True)
|
| 276 |
+
config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
|
| 277 |
+
config.setdefault("security", {}).setdefault("redact_secrets", True)
|
| 278 |
+
|
| 279 |
+
platforms = config.setdefault("platforms", {})
|
| 280 |
+
|
| 281 |
+
if os.environ.get("TELEGRAM_BOT_TOKEN"):
|
| 282 |
+
telegram = platforms.setdefault("telegram", {})
|
| 283 |
+
telegram.setdefault("enabled", True)
|
| 284 |
+
extra = telegram.setdefault("extra", {})
|
| 285 |
+
if os.environ.get("TELEGRAM_BASE_URL"):
|
| 286 |
+
extra.setdefault("base_url", os.environ["TELEGRAM_BASE_URL"])
|
| 287 |
+
extra.setdefault("base_file_url", os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"])
|
| 288 |
+
if os.environ.get("TELEGRAM_ALLOWED_USERS"):
|
| 289 |
+
config.setdefault("telegram", {}).setdefault("allow_from", [
|
| 290 |
+
item.strip()
|
| 291 |
+
for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
|
| 292 |
+
if item.strip()
|
| 293 |
+
])
|
| 294 |
+
|
| 295 |
+
path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
|
| 296 |
+
path.chmod(0o600)
|
| 297 |
+
PY
|
| 298 |
+
|
| 299 |
+
# ── Startup summary ───────────────────────────────────────────────────
|
| 300 |
+
echo ""
|
| 301 |
+
echo "Primary UI : ${PRIMARY_UI:-webui}"
|
| 302 |
+
echo "Model : ${MODEL_FOR_CONFIG:-unset}"
|
| 303 |
+
echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
|
| 304 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
|
| 305 |
+
echo "Telegram : enabled"
|
| 306 |
+
else
|
| 307 |
+
echo "Telegram : not configured"
|
| 308 |
+
fi
|
| 309 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 310 |
+
echo "Backup : ${BACKUP_DATASET} (every ${SYNC_INTERVAL:-600}s)"
|
| 311 |
+
else
|
| 312 |
+
echo "Backup : disabled"
|
| 313 |
+
fi
|
| 314 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 315 |
+
echo "CF Proxy : ${CLOUDFLARE_PROXY_URL}"
|
| 316 |
+
fi
|
| 317 |
+
echo "Router : 0.0.0.0:${PUBLIC_PORT}"
|
| 318 |
+
echo "WebUI : 127.0.0.1:${WEBUI_PORT}"
|
| 319 |
+
echo "Gateway : 127.0.0.1:${GATEWAY_API_PORT}"
|
| 320 |
+
echo "Dashboard : 127.0.0.1:${DASHBOARD_PORT}"
|
| 321 |
+
echo ""
|
| 322 |
+
|
| 323 |
+
# ── Graceful shutdown ─────────────────────────────────────────────────
|
| 324 |
+
graceful_shutdown() {
|
| 325 |
+
echo "Shutting down..."
|
| 326 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 327 |
+
python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."
|
| 328 |
+
fi
|
| 329 |
+
kill $(jobs -p) 2>/dev/null || true
|
| 330 |
+
exit 0
|
| 331 |
+
}
|
| 332 |
+
trap graceful_shutdown SIGTERM SIGINT
|
| 333 |
+
|
| 334 |
+
# ── Start the public-facing router (port 7861) ────────────────────────
|
| 335 |
+
node "$APP_DIR/health-server.js" &
|
| 336 |
+
HEALTH_PID=$!
|
| 337 |
+
|
| 338 |
+
if [ -n "${WEBHOOK_URL:-}" ]; then
|
| 339 |
+
python3 - <<'PY' >/dev/null 2>&1 &
|
| 340 |
+
import json, os, urllib.request
|
| 341 |
+
body = json.dumps({
|
| 342 |
+
"event": "restart",
|
| 343 |
+
"status": "success",
|
| 344 |
+
"message": "HuggingMes + Hermes WebUI has started.",
|
| 345 |
+
"model": os.environ.get("MODEL_FOR_CONFIG", ""),
|
| 346 |
+
}).encode()
|
| 347 |
+
req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST",
|
| 348 |
+
headers={"Content-Type": "application/json"})
|
| 349 |
+
urllib.request.urlopen(req, timeout=10).read()
|
| 350 |
+
PY
|
| 351 |
+
fi
|
| 352 |
+
|
| 353 |
+
# ── Launch Hermes dashboard (private; proxied via /hm/app) ────────────
|
| 354 |
+
echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
|
| 355 |
+
(hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
|
| 356 |
+
DASHBOARD_PID=$!
|
| 357 |
+
|
| 358 |
+
# ── Launch Hermes gateway ─────────────────────────────────────────────
|
| 359 |
+
echo "Launching Hermes gateway..."
|
| 360 |
+
(hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
|
| 361 |
+
GATEWAY_PID=$!
|
| 362 |
+
|
| 363 |
+
GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
|
| 364 |
+
ready=false
|
| 365 |
+
for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
|
| 366 |
+
if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
|
| 367 |
+
ready=true
|
| 368 |
+
break
|
| 369 |
+
fi
|
| 370 |
+
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
| 371 |
+
break
|
| 372 |
+
fi
|
| 373 |
+
sleep 1
|
| 374 |
+
done
|
| 375 |
+
|
| 376 |
+
if [ "$ready" != "true" ]; then
|
| 377 |
+
echo ""
|
| 378 |
+
echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
|
| 379 |
+
echo "----------------------------------------"
|
| 380 |
+
tail -40 "$HERMES_HOME/logs/gateway.log" || true
|
| 381 |
+
exit 1
|
| 382 |
+
fi
|
| 383 |
+
|
| 384 |
+
# ── Launch Hermes WebUI (nesquena/hermes-webui) ───────────────────────
|
| 385 |
+
# Points WebUI at the already-running Hermes agent venv and persists state
|
| 386 |
+
# under $HERMES_HOME/webui so hermes-sync.py backs it up.
|
| 387 |
+
export HERMES_WEBUI_AGENT_DIR="/opt/hermes"
|
| 388 |
+
export HERMES_WEBUI_PYTHON="/opt/hermes/.venv/bin/python"
|
| 389 |
+
export HERMES_WEBUI_HOST="127.0.0.1"
|
| 390 |
+
export HERMES_WEBUI_PORT
|
| 391 |
+
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-$HERMES_HOME/webui}"
|
| 392 |
+
export HERMES_WEBUI_DEFAULT_WORKSPACE="${HERMES_WEBUI_DEFAULT_WORKSPACE:-$HERMES_HOME/workspace}"
|
| 393 |
+
export HERMES_WEBUI_AUTO_INSTALL="0"
|
| 394 |
+
mkdir -p "$HERMES_WEBUI_STATE_DIR"
|
| 395 |
+
|
| 396 |
+
echo "Launching Hermes WebUI on 127.0.0.1:${WEBUI_PORT}..."
|
| 397 |
+
(cd "$WEBUI_REPO" && \
|
| 398 |
+
"$HERMES_WEBUI_PYTHON" "$WEBUI_REPO/server.py" 2>&1 | \
|
| 399 |
+
tee -a "$HERMES_HOME/logs/webui.log") &
|
| 400 |
+
WEBUI_PID=$!
|
| 401 |
+
|
| 402 |
+
# Wait for WebUI to bind its port (non-fatal on timeout — router handles it)
|
| 403 |
+
WEBUI_READY_TIMEOUT="${WEBUI_READY_TIMEOUT:-60}"
|
| 404 |
+
for ((i=0; i<WEBUI_READY_TIMEOUT; i++)); do
|
| 405 |
+
if (echo > "/dev/tcp/127.0.0.1/${WEBUI_PORT}") 2>/dev/null; then
|
| 406 |
+
echo "Hermes WebUI is up."
|
| 407 |
+
break
|
| 408 |
+
fi
|
| 409 |
+
if ! kill -0 "$WEBUI_PID" 2>/dev/null; then
|
| 410 |
+
echo "Warning: Hermes WebUI exited during startup. Last 20 log lines:"
|
| 411 |
+
tail -20 "$HERMES_HOME/logs/webui.log" || true
|
| 412 |
+
break
|
| 413 |
+
fi
|
| 414 |
+
sleep 1
|
| 415 |
+
done
|
| 416 |
+
|
| 417 |
+
# ── Periodic backup loop ──────────────────────────────────────────────
|
| 418 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 419 |
+
python3 -u "$APP_DIR/hermes-sync.py" loop &
|
| 420 |
+
fi
|
| 421 |
+
|
| 422 |
+
# ── Wait on the gateway (primary supervision target) ──────────────────
|
| 423 |
+
wait "$GATEWAY_PID"
|
| 424 |
+
|
| 425 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 426 |
+
echo "Gateway exited - syncing state before shutdown..."
|
| 427 |
+
python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: final sync failed."
|
| 428 |
+
fi
|