Spaces:
Paused
Paused
| # ============================================================ | |
| # Hermes Agent β Self-contained HF Spaces deployment (Stable v7.1) | |
| # Single Dockerfile: Gateway + Dashboard + Chat WebUI + Router | |
| # | |
| # HOW TO DEPLOY: | |
| # 1. Create HF Space β SDK: Docker β app_port: 7860 β Public | |
| # 2. Push ONLY this Dockerfile | |
| # 3. Set Secrets in Space Settings β Variables and secrets: | |
| # | |
| # GATEWAY_TOKEN β any strong password (your login) | |
| # | |
| # OPENROUTER_API_KEY β from openrouter.ai (recommended) | |
| # OR OPENAI_API_KEY / ANTHROPIC_API_KEY | |
| # | |
| # HF_TOKEN β write token from hf.co/settings/tokens | |
| # HF_BUCKET β Sanyam400/Hermes-storage | |
| # | |
| # API_SERVER_KEY β random long secret string | |
| # | |
| # [OPTIONAL TELEGRAM CONFIGURATION] | |
| # TELEGRAM_BOT_TOKEN β Your Telegram bot token from @BotFather | |
| # TELEGRAM_ALLOWED_USERS β Your numeric user ID (comma-separated if multiple) | |
| # | |
| # CLOUDFLARE_WORKERS_TOKEN β (RECOMMENDED) Cloudflare API token that auto-deploys | |
| # a Worker proxy so Telegram always works even when | |
| # HF Spaces blocks outbound IPs. Get it at: | |
| # dash.cloudflare.com β API Tokens β Create Token | |
| # β "Edit Cloudflare Workers" template | |
| # | |
| # TELEGRAM_API_BASE β (Alternative) If you already have a Worker proxy URL, | |
| # set it here directly instead of CLOUDFLARE_WORKERS_TOKEN | |
| # ============================================================ | |
| FROM nousresearch/hermes-agent:latest | |
| USER root | |
| # ββ Global Failsafe Environment Configuration ββββββββββββββββββ | |
| # Interactive terminal variables guarantee the agent runs console commands cleanly | |
| # SYNC_INTERVAL set to 30 seconds natively guarantees a 30-second background backup loop! | |
| ENV PYTHONUNBUFFERED=1 \ | |
| HERMES_HOME=/data \ | |
| HERMES_WEBUI_AGENT_DIR=/opt/hermes \ | |
| HERMES_WEBUI_ONBOARDING_OPEN=1 \ | |
| NODE_OPTIONS=--no-deprecation \ | |
| HF_XET_HIGH_PERFORMANCE=1 \ | |
| PORT=7860 \ | |
| HF_HUB_ENABLE_HF_TRANSFER=1 \ | |
| TERM=xterm-256color \ | |
| SHELL=/bin/bash \ | |
| SYNC_INTERVAL=30 \ | |
| # ββ BROWSER FIX: Two env vars set because different code paths read different ones: | |
| # AGENT_BROWSER_ARGS β read by the agent-browser CLI binary itself | |
| # (official var per agent-browser.dev docs) | |
| # AGENT_BROWSER_CHROME_FLAGS β read by Hermes internal browser_tool.py | |
| # (Hermes-specific, added in v0.14+) | |
| # Setting both guarantees the no-sandbox flags reach Chromium regardless of | |
| # which code path is active. HF Spaces uses AppArmor userns restrictions | |
| # which cause Chromium to refuse to start without --no-sandbox, timing out | |
| # browser_navigate after 60s. | |
| AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| AGENT_BROWSER_CHROME_FLAGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| # Playwright Chromium cache path and stealth init script path | |
| PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright \ | |
| AGENT_BROWSER_INIT_SCRIPT=/opt/hermes-stealth/stealth-init.js | |
| # ββ System deps & HF CLI installation ββββββββββββββββββββββββ | |
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| ca-certificates curl jq git nodejs npm python3 netcat-openbsd tar gzip unzip dnsutils \ | |
| # ββ BROWSER FIX: Chromium runtime deps needed for headless operation | |
| # in a container without a display. These prevent the "missing shared | |
| # library" class of errors that also cause browser_navigate to fail. | |
| libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \ | |
| libcups2 libdrm2 libdbus-1-3 libxcb1 libxkbcommon0 libx11-6 \ | |
| libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 \ | |
| libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \ | |
| && rm -rf /var/lib/apt/lists/* \ | |
| && uv pip install --python /opt/hermes/.venv/bin/python \ | |
| --no-cache-dir "huggingface_hub>=0.22" pyyaml \ | |
| && curl -LsSf https://hf.co/cli/install.sh | bash \ | |
| && ( [ -f /root/.local/bin/hf ] && mv /root/.local/bin/hf /usr/local/bin/hf || true ) \ | |
| && chmod +x /usr/local/bin/hf || true | |
| # ββ Clone Hermes WebUI ββββββββββββββββββββββββββββββββββββββββ | |
| RUN git clone --depth 1 https://github.com/nesquena/hermes-webui.git /opt/hermes-webui \ | |
| && ( [ -f /opt/hermes-webui/requirements.txt ] \ | |
| && /opt/hermes/.venv/bin/pip install --no-cache-dir \ | |
| -r /opt/hermes-webui/requirements.txt \ | |
| || true ) | |
| # ββ Write build-time WebUI & Agent bypass patch ββββββββββββββββ | |
| RUN cat << 'EOF' > /tmp/patch_workspace.py | |
| try: | |
| import os, re | |
| # Patch WebUI Links & Login Redundant Decorators | |
| web_dir = "/opt/hermes-webui" | |
| if os.path.exists(web_dir): | |
| for root, dirs, files in os.walk(web_dir): | |
| dirs[:] = [d for d in dirs if not d.startswith('.')] | |
| for fname in files: | |
| fpath = os.path.join(root, fname) | |
| try: | |
| with open(fpath, 'r', encoding='utf-8', errors='ignore') as f: | |
| content = f.read() | |
| original = content | |
| content = re.sub(r'https?://127\.0\.0\.1:9119/?', '/dashboard', content) | |
| content = re.sub(r'https?://localhost:9119/?', '/dashboard', content) | |
| content = content.replace('127.0.0.1:9119', '/dashboard') | |
| content = content.replace('localhost:9119', '/dashboard') | |
| content = re.sub(r'":9119"', '"/dashboard"', content) | |
| content = re.sub(r'":9119/"', '"/dashboard"', content) | |
| if fname.endswith(".py"): | |
| content = content.replace("password_enabled = true", "password_enabled = False") | |
| content = content.replace("password_enabled=True", "password_enabled=False") | |
| content = content.replace("auth_enabled = true", "auth_enabled = False") | |
| content = content.replace("auth_enabled=True", "auth_enabled=False") | |
| if content != original: | |
| with open(fpath, 'w', encoding='utf-8') as f: | |
| f.write(content) | |
| except Exception: | |
| pass | |
| print("Bypass patching completed successfully.") | |
| except Exception as e: | |
| print(f"Bypass patching failed silently to avoid interrupting build: {e}") | |
| EOF | |
| RUN python3 /tmp/patch_workspace.py \ | |
| && rm /tmp/patch_workspace.py \ | |
| && chown -R hermes:hermes /opt/hermes-webui | |
| # ββ Node router deps ββββββββββββββββββββββββββββββββββββββββββ | |
| RUN mkdir -p /opt/router \ | |
| && cd /opt/router \ | |
| && npm init -y --quiet \ | |
| && npm install --quiet --no-fund http-proxy | |
| # ββ Write the Node.js reverse proxy ββββββββββββββββββββββββββ | |
| RUN cat > /opt/router/server.js << 'ENDJS' | |
| 'use strict'; | |
| var http = require('http'); | |
| var urlParser = require('url'); | |
| var proxy = require('http-proxy').createProxyServer({ proxyTimeout: 120000 }); | |
| var PORT = parseInt(process.env.PORT || '7860', 10); | |
| var GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || ''; | |
| var DASHBOARD_PATHS = [ | |
| '/dashboard', '/skills', '/plugins', '/mcp', '/webhooks', | |
| '/pairing', '/profiles', '/config', '/keys', '/system', | |
| '/cron', '/models', '/logs', '/env' | |
| ]; | |
| var GATEWAY_PATHS = [ | |
| '/v1', '/health', '/status' | |
| ]; | |
| function isDashboardPath(pathname) { | |
| for (var i = 0; i < DASHBOARD_PATHS.length; i++) { | |
| var p = DASHBOARD_PATHS[i]; | |
| if (pathname === p || pathname.indexOf(p + '/') === 0) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function isGatewayPath(pathname) { | |
| for (var i = 0; i < GATEWAY_PATHS.length; i++) { | |
| var p = GATEWAY_PATHS[i]; | |
| if (pathname === p || pathname.indexOf(p + '/') === 0) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| proxy.on('error', function(err, req, res) { | |
| console.error('[router] proxy error:', err.message, req.url); | |
| if (res && res.socket && !res.headersSent) { | |
| res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' }); | |
| res.end( | |
| '<!DOCTYPE html><html><head><meta charset="utf-8">' + | |
| '<meta http-equiv="refresh" content="5"><title>Startingβ¦</title>' + | |
| '<style>body{background:#0d0f14;color:#e2e2e8;font-family:system-ui;' + | |
| 'display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' + | |
| '.c{text-align:center}.spin{font-size:3rem;animation:s 1s linear infinite}' + | |
| '@keyframes s{to{transform:rotate(360deg)}}</style></head>' + | |
| '<body><div class="c"><div class="spin">βοΈ</div>' + | |
| '<h2 style="margin:.5rem 0">Hermes is startingβ¦</h2>' + | |
| '<p style="color:#686c7a">This page refreshes automatically every 5 seconds</p>' + | |
| '</div></body></html>' | |
| ); | |
| } | |
| }); | |
| function parseCookies(req) { | |
| var out = {}; | |
| var cookieHeader = req.headers.cookie || ''; | |
| cookieHeader.split(';').forEach(function(c) { | |
| var idx = c.indexOf('='); | |
| if (idx < 1) return; | |
| out[c.slice(0, idx).trim()] = c.slice(idx + 1).trim(); | |
| }); | |
| return out; | |
| } | |
| function authed(req) { | |
| if (!GATEWAY_TOKEN) return true; | |
| var cleanGatewayToken = GATEWAY_TOKEN.trim(); | |
| try { | |
| var url = req.url || ''; | |
| var qIdx = url.indexOf('?'); | |
| if (qIdx !== -1) { | |
| var search = url.slice(qIdx + 1); | |
| var params = search.split('&'); | |
| for (var i = 0; i < params.length; i++) { | |
| var pair = params[i].split('='); | |
| // FIX: Correctly check array key pair elements to support iframe token SSO query bypasses | |
| if (pair[0] === 'token' && decodeURIComponent(pair[1] || '').trim() === cleanGatewayToken) { | |
| return true; | |
| } | |
| } | |
| } | |
| } catch (e) {} | |
| var cookies = parseCookies(req); | |
| if (cookies['hm_tok'] && decodeURIComponent(cookies['hm_tok']).trim() === cleanGatewayToken) { | |
| return true; | |
| } | |
| var auth = req.headers['authorization'] || ''; | |
| if (auth.trim() === 'Bearer ' + cleanGatewayToken) return true; | |
| return false; | |
| } | |
| function loginPage(errMsg) { | |
| var errHtml = errMsg ? '<div class="err">⚠ ' + errMsg + '</div>' : ''; | |
| return '<!DOCTYPE html><html lang="en"><head>' + | |
| '<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">' + | |
| '<title>Hermes — Sign in</title>' + | |
| '<style>*{box-sizing:border-box;margin:0;padding:0}' + | |
| 'body{background:#0d0f14;color:#e2e2e8;font-family:system-ui;' + | |
| 'display:flex;align-items:center;justify-content:center;min-height:100vh}' + | |
| '.card{background:#161920;border:1px solid #252830;border-radius:18px;' + | |
| 'padding:2.6rem 2.2rem;width:100%;max-width:400px}' + | |
| '.icon{font-size:2.6rem;margin-bottom:.5rem}' + | |
| 'h1{font-size:1.45rem;margin-bottom:.2rem}' + | |
| '.sub{color:#5a5e6b;font-size:.875rem;margin-bottom:1.8rem}' + | |
| 'label{display:block;font-size:.8rem;color:#8a8e9b;margin-bottom:.4rem}' + | |
| 'input{width:100%;padding:.78rem 1rem;background:#0d0f14;border:1px solid #2a2d38;' + | |
| 'border-radius:9px;color:#e2e2e8;font-size:1rem;margin-bottom:1.2rem;outline:none}' + | |
| 'input:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.18)}' + | |
| 'button{width:100%;padding:.8rem;background:#7c3aed;border:none;border-radius:9px;' + | |
| 'color:#fff;font-size:1rem;font-weight:600;cursor:pointer;transition:.15s}' + | |
| 'button:hover{background:#6d28d9}' + | |
| '.err{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);' + | |
| 'color:#f87171;border-radius:8px;padding:.6rem .9rem;font-size:.85rem;margin-bottom:1.1rem}' + | |
| '.note{font-size:.75rem;color:#3a3d4a;margin-top:1rem;text-align:center}' + | |
| '</style></head><body><div class="card">' + | |
| '<div class="icon">🥯</div>' + | |
| '<h1>Hermes Agent</h1>' + | |
| '<p class="sub">Enter your gateway token to access the workspace.</p>' + | |
| errHtml + | |
| '<form method="POST" action="/_login">' + | |
| '<label for="tok">Gateway Token</label>' + | |
| '<input id="tok" type="password" name="token" placeholder="Your GATEWAY_TOKEN secret" autofocus autocomplete="current-password"/>' + | |
| '<button type="submit">Sign in →</button>' + | |
| '</form>' + | |
| '<p class="note">Set GATEWAY_TOKEN in your Space secrets to activate this gate.</p>' + | |
| '</div></body></html>'; | |
| } | |
| var server = http.createServer(function(req, res) { | |
| var url = req.url || '/'; | |
| var referer = req.headers.referer || ''; | |
| var pathname = urlParser.parse(url).pathname || '/'; | |
| if (pathname === '/login' || pathname === '/login/') { | |
| res.writeHead(302, { 'Location': '/' }); | |
| return res.end(); | |
| } | |
| // Route: /health check (public) | |
| if (pathname === '/health' || pathname === '/health/') { | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| return res.end(JSON.stringify({ ok: true, ts: new Date().toISOString(), port: PORT })); | |
| } | |
| // Route: /_login page | |
| if (pathname === '/_login' || pathname === '/_login/') { | |
| if (req.method === 'POST') { | |
| var body = ''; | |
| req.on('data', function(d) { body += d; }); | |
| req.on('end', function() { | |
| var tok = new URLSearchParams(body).get('token') || ''; | |
| var cleanGatewayToken = GATEWAY_TOKEN.trim(); | |
| var cleanInputToken = tok.trim(); | |
| if (!cleanGatewayToken || cleanInputToken === cleanGatewayToken) { | |
| var cookieFlags = '; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=31536000'; | |
| var cookies = parseCookies(req); | |
| var targetScope = cookies['hm_redirect'] || cookies['hm_scope'] || 'webui'; | |
| var destination = (targetScope === 'dashboard') ? '/dashboard' : '/'; | |
| res.writeHead(302, { | |
| 'Set-Cookie': [ | |
| 'hm_tok=' + encodeURIComponent(cleanInputToken) + cookieFlags, | |
| 'hm_redirect=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + cookieFlags | |
| ], | |
| 'Location': destination | |
| }); | |
| return res.end(); | |
| } | |
| res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' }); | |
| res.end(loginPage('Invalid token β try again.')); | |
| }); | |
| return; | |
| } | |
| res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); | |
| return res.end(loginPage('')); | |
| } | |
| // Mandatory Authentication Check | |
| if (!authed(req)) { | |
| var isApiOrAsset = pathname.indexOf('/v1/') === 0 || | |
| pathname.indexOf('/api/') === 0 || | |
| pathname.indexOf('/static/') === 0 || | |
| pathname.indexOf('/assets/') === 0 || | |
| pathname.indexOf('/openapi.json') === 0; | |
| if (isApiOrAsset) { | |
| res.writeHead(401, { 'Content-Type': 'application/json' }); | |
| return res.end(JSON.stringify({ error: 'Unauthorized', hint: 'Please login at /_login.' })); | |
| } | |
| var isDashboardNav = pathname === '/dashboard' || pathname.indexOf('/dashboard/') === 0; | |
| var redirectScope = isDashboardNav ? 'dashboard' : 'webui'; | |
| var cookieFlags = '; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=31536000'; | |
| res.writeHead(302, { | |
| 'Set-Cookie': 'hm_redirect=' + redirectScope + cookieFlags, | |
| 'Location': '/_login' | |
| }); | |
| return res.end(); | |
| } | |
| // Route: Gateway endpoints (Port 8642) | |
| var isGateway = isGatewayPath(pathname); | |
| if (isGateway) { | |
| return proxy.web(req, res, { target: 'http://127.0.0.1:8642', changeOrigin: false }); | |
| } | |
| // ββ Session Cookie/Referer Scope router for Asset Separation ββ | |
| var cookies = parseCookies(req); | |
| var activeScope = cookies['hm_scope'] || 'webui'; | |
| var isDashboard = isDashboardPath(pathname); | |
| var scopeChanged = false; | |
| if (pathname === '/dashboard' || pathname.indexOf('/dashboard/') === 0) { | |
| if (activeScope !== 'dashboard') { | |
| isDashboard = true; | |
| activeScope = 'dashboard'; | |
| scopeChanged = true; | |
| } | |
| } else if (pathname === '/' || pathname === '/_login' || pathname === '/_login/') { | |
| if (activeScope !== 'webui') { | |
| isDashboard = false; | |
| activeScope = 'webui'; | |
| scopeChanged = true; | |
| } | |
| } else if (referer) { | |
| try { | |
| var refPath = urlParser.parse(referer).pathname || ''; | |
| if (refPath === '/dashboard' || refPath.indexOf('/dashboard/') === 0 || isDashboardPath(refPath)) { | |
| isDashboard = true; | |
| if (activeScope !== 'dashboard') { | |
| activeScope = 'dashboard'; | |
| scopeChanged = true; | |
| } | |
| } | |
| } catch (e) {} | |
| } | |
| if (!isDashboard && activeScope === 'dashboard') { | |
| var isAssetOrApi = pathname.indexOf('/assets/') === 0 || | |
| pathname.indexOf('/static/') === 0 || | |
| pathname.indexOf('/api/') === 0 || | |
| pathname.indexOf('/openapi.json') === 0; | |
| if (isAssetOrApi) { | |
| isDashboard = true; | |
| } | |
| } | |
| if (scopeChanged) { | |
| var cookieFlags = '; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=31536000'; | |
| res.setHeader('Set-Cookie', 'hm_scope=' + activeScope + cookieFlags); | |
| } | |
| if (isDashboard) { | |
| return proxy.web(req, res, { target: 'http://127.0.0.1:9119', changeOrigin: true }); | |
| } | |
| return proxy.web(req, res, { target: 'http://127.0.0.1:8787', changeOrigin: false }); | |
| }); | |
| server.on('upgrade', function(req, socket, head) { | |
| var url = req.url || '/'; | |
| var referer = req.headers.referer || ''; | |
| var pathname = urlParser.parse(url).pathname || '/'; | |
| if (!authed(req)) { | |
| socket.destroy(); | |
| return; | |
| } | |
| var cookies = parseCookies(req); | |
| var activeScope = cookies['hm_scope'] || 'webui'; | |
| var isDashboard = isDashboardPath(pathname); | |
| if (pathname === '/dashboard' || pathname.indexOf('/dashboard/') === 0) { | |
| isDashboard = true; | |
| } else if (referer) { | |
| try { | |
| var refPath = urlParser.parse(referer).pathname || ''; | |
| if (refPath === '/dashboard' || refPath.indexOf('/dashboard/') === 0 || isDashboardPath(refPath)) { | |
| isDashboard = true; | |
| } | |
| } catch (e) {} | |
| } | |
| if (!isDashboard && activeScope === 'dashboard') { | |
| isDashboard = true; | |
| } | |
| var target = isDashboard | |
| ? 'http://127.0.0.1:9119' | |
| : (isGatewayPath(pathname) ? 'http://127.0.0.1:8642' : 'http://127.0.0.1:8787'); | |
| proxy.ws(req, socket, head, { target: target, changeOrigin: (target.indexOf('9119') >= 0) }); | |
| }); | |
| server.listen(PORT, '0.0.0.0', function() { | |
| console.log('[router] Transparent Reverse Proxy active on port ' + PORT); | |
| }); | |
| ENDJS | |
| # ββ BROWSER FIX: Pre-write agent-browser config with no-sandbox flags βββββββββ | |
| # agent-browser reads config from ~/.agent-browser/config.json (user-level). | |
| # The hermes user's HOME is /home/hermes, so that is the authoritative path. | |
| # We also write to /opt/hermes/.agent-browser as a belt-and-suspenders fallback | |
| # in case HOME env is overridden at runtime. | |
| RUN mkdir -p /home/hermes/.agent-browser /opt/hermes/.agent-browser \ | |
| && cat > /home/hermes/.agent-browser/config.json << 'ENDJSON' | |
| { | |
| "$schema": "https://agent-browser.dev/schema.json", | |
| "args": "--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer", | |
| "headless": true, | |
| "ignoreHttpsErrors": true | |
| } | |
| ENDJSON | |
| RUN cp /home/hermes/.agent-browser/config.json /opt/hermes/.agent-browser/config.json | |
| # ββ BROWSER FIX: Install Playwright Chromium as root into a shared path βββββββ | |
| # The base image installs Playwright under /root/.cache which is not accessible | |
| # by the hermes user. We reinstall it into PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright | |
| # (set in ENV above) so the hermes user can find and execute Chromium. | |
| # We also run `npx playwright install-deps chromium` to pull any missing OS libs. | |
| RUN mkdir -p /opt/hermes/.playwright \ | |
| && PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright \ | |
| npx --yes playwright install chromium \ | |
| && PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright \ | |
| npx playwright install-deps chromium 2>/dev/null || true \ | |
| && chown -R hermes:hermes /opt/hermes/.playwright | |
| # ββ ANTI-BOT: Install playwright-stealth into Hermes venv βββββββββββββββββββββ | |
| # playwright-stealth patches the 11 most-detected automation signals: | |
| # navigator.webdriver, HeadlessChrome UA, missing plugins array, Chrome runtime, | |
| # permissions API, WebGL vendor, language inconsistency, hairline feature, | |
| # iframe.contentWindow, media codecs, and broken toString() on overrides. | |
| # It is installed into the same venv Hermes uses so it is available to any | |
| # Python code the agent runs. No existing packages are touched. | |
| RUN uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir "playwright-stealth>=1.0.5" | |
| # ββ ANTI-BOT: Patch agent-browser config with stealth Chromium flags βββββββββββ | |
| # These flags are the well-known set for reducing headless fingerprint leakage. | |
| # They are ADDITIVE β combined with the existing no-sandbox flags already present. | |
| # | |
| # Key flags: | |
| # --disable-blink-features=AutomationControlled β hides navigator.webdriver=true | |
| # --disable-features=IsolateOrigins,... β reduces isolation fingerprint | |
| # --window-size=1920,1080 β realistic viewport, not headless default | |
| # --user-agent=... β real Chrome UA, no "HeadlessChrome" marker | |
| # --lang=en-US β consistent language header | |
| # --disable-extensions-except / --load-extension β not needed; excluded cleanly | |
| # | |
| # NOTE: We do NOT use --headless=new here because agent-browser sets headless | |
| # mode through its own config "headless":true β adding it in args would conflict. | |
| RUN cat > /home/hermes/.agent-browser/config.json << 'ENDJSON' | |
| { | |
| "$schema": "https://agent-browser.dev/schema.json", | |
| "args": "--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--disable-features=IsolateOrigins,site-per-process,--window-size=1920,1080,--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36,--lang=en-US,--accept-lang=en-US,en;q=0.9,--disable-notifications,--no-first-run,--no-default-browser-check,--disable-background-networking,--disable-sync,--disable-translate", | |
| "headless": true, | |
| "ignoreHttpsErrors": true | |
| } | |
| ENDJSON | |
| RUN cp /home/hermes/.agent-browser/config.json /opt/hermes/.agent-browser/config.json | |
| # ββ ANTI-BOT: Write a stealth init-script that Hermes injects into every page ββ | |
| # Hermes supports AGENT_BROWSER_INIT_SCRIPT β a JS snippet executed before any | |
| # page script runs, in every frame. This is the most reliable way to patch | |
| # navigator.webdriver and other JS-visible automation signals because it runs | |
| # before the page's own JS can observe them. | |
| # | |
| # The script below patches the 6 JS-level signals that pure Chromium flags cannot fix: | |
| # 1. navigator.webdriver β delete entirely (most important) | |
| # 2. navigator.plugins β fake a realistic plugins array | |
| # 3. navigator.languages β set to realistic value | |
| # 4. window.chrome β add chrome.runtime so sites see a real Chrome | |
| # 5. Notification.permission β return "default" not "denied" (headless default) | |
| # 6. navigator.permissions β query returns "prompt" for notifications | |
| RUN mkdir -p /opt/hermes-stealth && cat > /opt/hermes-stealth/stealth-init.js << 'ENDJS' | |
| // Stealth init script β injected by agent-browser before every page script | |
| // Patches JS-visible automation signals that Chromium flags cannot fix. | |
| (function() { | |
| // 1. Remove webdriver flag β #1 bot detection signal | |
| try { | |
| Object.defineProperty(navigator, 'webdriver', { | |
| get: () => undefined, | |
| configurable: true | |
| }); | |
| } catch(e) {} | |
| // 2. Realistic plugins array (headless Chrome has 0 plugins) | |
| try { | |
| Object.defineProperty(navigator, 'plugins', { | |
| get: () => { | |
| const arr = [ | |
| { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, | |
| { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' }, | |
| { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' } | |
| ]; | |
| arr.__proto__ = PluginArray.prototype; | |
| return arr; | |
| }, | |
| configurable: true | |
| }); | |
| } catch(e) {} | |
| // 3. Consistent languages | |
| try { | |
| Object.defineProperty(navigator, 'languages', { | |
| get: () => ['en-US', 'en'], | |
| configurable: true | |
| }); | |
| } catch(e) {} | |
| // 4. Add chrome.runtime so sites see a real Chrome install | |
| try { | |
| if (!window.chrome) { | |
| window.chrome = { runtime: {} }; | |
| } else if (!window.chrome.runtime) { | |
| window.chrome.runtime = {}; | |
| } | |
| } catch(e) {} | |
| // 5. Permissions API β return "prompt" for notifications instead of "denied" | |
| try { | |
| const originalQuery = window.navigator.permissions.query; | |
| window.navigator.permissions.__proto__.query = function(params) { | |
| if (params.name === 'notifications') { | |
| return Promise.resolve({ state: Notification.permission === 'denied' ? 'prompt' : Notification.permission }); | |
| } | |
| return originalQuery.call(this, params); | |
| }; | |
| } catch(e) {} | |
| // 6. Consistent platform | |
| try { | |
| Object.defineProperty(navigator, 'platform', { | |
| get: () => 'Linux x86_64', | |
| configurable: true | |
| }); | |
| } catch(e) {} | |
| })(); | |
| ENDJS | |
| # ββ Write start.sh ββββββββββββββββββββββββββββββββββββββββββββ | |
| RUN cat > /opt/start.sh << 'ENDSH' | |
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH" | |
| HH="${HERMES_HOME:-/data}" | |
| mkdir -p "$HH" "$HH/logs" "$HH/config" "$HH/memory" | |
| echo "ββββββββββββββββββββββββββββββββββββββββββββ" | |
| echo "β Hermes Agent β HF Spaces bootloader β" | |
| echo "ββββββββββββββββββββββββββββββββββββββββββββ" | |
| # ββ 0. BROWSER FIX: Ensure /tmp agent-browser socket dir is writable ββββββββββ | |
| # agent-browser creates Unix socket files under /tmp/agent-browser-* for IPC. | |
| # We pre-create the subdir and make it world-writable. | |
| # NOTE: We do NOT chmod /tmp itself β HF Spaces runs as an unprivileged user | |
| # who cannot chmod system dirs. Attempting it crashes the boot under set -e. | |
| # /tmp is already world-writable on all HF Space base images; no chmod needed. | |
| mkdir -p /tmp/agent-browser-sockets || true | |
| chmod 777 /tmp/agent-browser-sockets 2>/dev/null || true | |
| # ββ 1. BACKGROUND FRONTEND UPDATES ββββββββββββββββββββββββββββ | |
| # WebUI git pull runs in background β never blocks boot | |
| echo "[boot] π Checking for WebUI updates in background..." | |
| (cd /opt/hermes-webui && git pull >/dev/null 2>&1) & | |
| # ββ 2. FULL SYNCHRONOUS RESTORE FROM HF BUCKET ββββββββββββββββ | |
| # WHY SYNCHRONOUS: | |
| # The Dashboard and WebUI read ALL their data (sessions, skills, state, | |
| # config) from /data at startup. If services start before the bucket | |
| # restore finishes, they boot with an empty /data and show blank screens. | |
| # Restarting them after restore completes is messy and unreliable. | |
| # The correct fix is: restore EVERYTHING first, THEN start services. | |
| # | |
| # PERFORMANCE: | |
| # hf sync with HF_HUB_ENABLE_HF_TRANSFER=1 (already set in ENV) uses the | |
| # xet high-speed transfer protocol β 49,000 files sync in ~15-25 seconds | |
| # on a typical HF Space, not minutes. This is fast enough to do upfront. | |
| # | |
| # DATABASE STRATEGY: | |
| # .db files ARE restored synchronously. The gateway hasn't started yet so | |
| # there is zero risk of write-conflict. After restore we run integrity check | |
| # and WAL mode enable before any service touches the files. | |
| if [ -n "${HF_TOKEN:-}" ] && [ -n "${HF_BUCKET:-}" ]; then | |
| export HF_TOKEN="${HF_TOKEN}" | |
| echo "[boot] π¦ Restoring complete workspace from HF bucket (synchronous)..." | |
| echo "[boot] This ensures Dashboard and WebUI open with your full data." | |
| if hf sync "hf://buckets/${HF_BUCKET}" "$HH" --exclude ".venv/**" --exclude "**/venv/**" --exclude "venv/**" --exclude ".cache/**" --exclude "**/.cache/**" --exclude "node_modules/**" --exclude "**/node_modules/**" --exclude "__pycache__/**" --exclude "**/__pycache__/**" --exclude "**/*.pyc" --exclude "**/*.log" --exclude "logs/**" --exclude "**/logs/**" --exclude "**/*.db-wal" --exclude "**/*.db-shm" --exclude "**/*.db-journal" 2>/dev/null; then | |
| echo "[boot] β Workspace restored β all data available before services start" | |
| else | |
| echo "[boot] β οΈ Bucket empty or first run β starting fresh" | |
| fi | |
| fi | |
| # ββ 3. AUTO SQLITE INTEGRITY RECOVERY & WAL ENABLING βββββββββ | |
| echo "[boot] π©Ί Checking database integrity & enabling WAL mode..." | |
| python3 - <<'PY_SQLITE_RECOVER' | |
| import os, sqlite3 | |
| db_dir = "/data" | |
| if os.path.exists(db_dir): | |
| for fname in os.listdir(db_dir): | |
| if fname.endswith(".db"): | |
| db_path = os.path.join(db_dir, fname) | |
| try: | |
| conn = sqlite3.connect(db_path) | |
| cursor = conn.cursor() | |
| cursor.execute("PRAGMA integrity_check;") | |
| res = cursor.fetchone() | |
| # FIX: res is a tuple e.g. ("ok",) β compare the first element | |
| if res and res[0] == "ok": | |
| cursor.execute("PRAGMA journal_mode=WAL;") | |
| cursor.execute("PRAGMA synchronous=NORMAL;") | |
| conn.commit() | |
| print(f"[boot-recover] Database OK and WAL enabled: {fname}") | |
| else: | |
| raise Exception(f"Integrity check failed: {res[0] if res else 'Unknown'}") | |
| conn.close() | |
| except Exception as e: | |
| print(f"[boot-recover] SQLite database corrupt/locked: {db_path} ({e})") | |
| for suffix in ["", "-journal", "-wal", "-shm"]: | |
| p = db_path + suffix if suffix else db_path | |
| if os.path.exists(p): | |
| try: | |
| os.remove(p) | |
| except Exception: | |
| pass | |
| print(f"[boot-recover] Reset corrupted file: {fname}") | |
| PY_SQLITE_RECOVER | |
| # ββ 4. MERGE BUILT-IN SKILLS INTO PERSISTENT STORAGE ββββββββββ | |
| echo "[boot] π§© Merging 79 built-in skills into persistent storage..." | |
| for src_skills in /opt/hermes/skills /opt/hermes/hermes_cli/skills /opt/hermes/hermes/skills; do | |
| if [ -d "$src_skills" ]; then | |
| mkdir -p "$HH/skills" | |
| cp -rn "$src_skills"/* "$HH/skills/" 2>/dev/null || true | |
| fi | |
| done | |
| # ββ 5. INTEGRITY LINKING OF WEBUI SETTINGS ββββββββββββββββββββ | |
| echo "[boot] βοΈ Linking WebUI configuration assets..." | |
| touch "$HH/webui_settings.json" | |
| if [ ! -s "$HH/webui_settings.json" ]; then | |
| echo '{"password":"","password_enabled":false,"auth_enabled":false}' > "$HH/webui_settings.json" | |
| else | |
| if jq . "$HH/webui_settings.json" >/dev/null 2>&1; then | |
| jq '.password="" | .password_enabled=false | .auth_enabled=false' "$HH/webui_settings.json" > "$HH/webui_settings.json.tmp" && mv "$HH/webui_settings.json.tmp" "$HH/webui_settings.json" | |
| else | |
| echo '{"password":"","password_enabled":false,"auth_enabled":false}' > "$HH/webui_settings.json" | |
| fi | |
| fi | |
| rm -f /opt/hermes-webui/settings.json | |
| ln -sf "$HH/webui_settings.json" /opt/hermes-webui/settings.json | |
| # ββ 6. MERGE AND EXPORT ENVIRONMENT CREDENTIALS βββββββββββββββββ | |
| ENV_FILE="$HH/.env" | |
| echo "[boot] π Synchronizing and exporting environment credentials..." | |
| touch "$ENV_FILE" | |
| TMP_ENV=$(mktemp) | |
| if [ -f "$ENV_FILE" ]; then | |
| cat "$ENV_FILE" > "$TMP_ENV" | |
| fi | |
| upsert_key() { | |
| local key="$1" | |
| local val="$2" | |
| if [ -n "$val" ]; then | |
| sed -i "/^${key}=/d" "$TMP_ENV" || true | |
| echo "${key}=${val}" >> "$TMP_ENV" | |
| fi | |
| } | |
| upsert_key "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY:-}" | |
| upsert_key "OPENAI_API_KEY" "${OPENAI_API_KEY:-}" | |
| upsert_key "ANTHROPIC_API_KEY" "${ANTHROPIC_API_KEY:-}" | |
| upsert_key "HF_TOKEN" "${HF_TOKEN:-}" | |
| upsert_key "API_SERVER_KEY" "${API_SERVER_KEY:-}" | |
| # ββ BROWSER FIX: Always persist the Chrome flags into .env so Hermes gateway | |
| # picks them up even if the ENV block above was somehow not inherited ββββββββββ | |
| upsert_key "AGENT_BROWSER_ARGS" "--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" | |
| upsert_key "AGENT_BROWSER_CHROME_FLAGS" "--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" | |
| upsert_key "PLAYWRIGHT_BROWSERS_PATH" "/opt/hermes/.playwright" | |
| upsert_key "AGENT_BROWSER_INIT_SCRIPT" "/opt/hermes-stealth/stealth-init.js" | |
| if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then | |
| upsert_key "TELEGRAM_BOT_TOKEN" "${TELEGRAM_BOT_TOKEN}" | |
| USERS="${TELEGRAM_ALLOWED_USERS:-}" | |
| if [ -n "$USERS" ]; then | |
| if [[ ! "$USERS" =~ ^\[.*\]$ ]]; then | |
| USERS="[$USERS]" | |
| fi | |
| upsert_key "TELEGRAM_ALLOWED_USERS" "$USERS" | |
| fi | |
| fi | |
| mv "$TMP_ENV" "$ENV_FILE" | |
| chmod 600 "$ENV_FILE" | |
| # ββ 7. TELEGRAM CLOUDFLARE PROXY SETUP ββββββββββββββββββββββββ | |
| # Runs SYNCHRONOUSLY before the gateway starts. | |
| # This is the only correct order: the gateway reads TELEGRAM_API_BASE once | |
| # at startup β writing it to .env after the gateway is already running has | |
| # no effect. We must resolve the proxy URL first, export it, then start. | |
| # | |
| # The Cloudflare Worker deploy is 3 HTTP calls (~3-5 seconds total) so it | |
| # adds negligible time to boot vs the data restore that already happened. | |
| # | |
| # THREE MODES (checked in order): | |
| # Mode A β CLOUDFLARE_WORKERS_TOKEN set β auto-deploy Worker, get proxy URL | |
| # Mode B β TELEGRAM_API_BASE already set as Space secret β use it directly | |
| # Mode C β neither set β direct connection (may be intermittently blocked) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TELEGRAM_PROXY_URL="" | |
| # ββ Mode B first: if TELEGRAM_API_BASE is already set as a secret, use it βββββ | |
| # Check this BEFORE attempting CF deploy β no point deploying a Worker if the | |
| # user already has a working proxy URL configured. | |
| if [ -n "${TELEGRAM_API_BASE:-}" ]; then | |
| TELEGRAM_PROXY_URL="${TELEGRAM_API_BASE}" | |
| echo "[boot-tg] π‘ Using existing TELEGRAM_API_BASE secret: ${TELEGRAM_PROXY_URL}" | |
| fi | |
| # ββ Mode A: Auto-provision Cloudflare Worker proxy ββββββββββββ | |
| # Only runs if no proxy URL is already set (Mode B above didn't fire). | |
| # Entire deploy logic runs inside Python β zero shell quoting issues. | |
| if [ -z "$TELEGRAM_PROXY_URL" ] && [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then | |
| echo "[boot] βοΈ CLOUDFLARE_WORKERS_TOKEN found β auto-provisioning Telegram proxy Worker..." | |
| TELEGRAM_PROXY_URL=$(python3 - << 'PYEOF' | |
| import sys, json, urllib.request, urllib.error, os, re, socket, time | |
| TOKEN = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip() | |
| SPACE = os.environ.get("SPACE_HOST", "") | |
| def cf_req(method, path, body=None, content_type="application/json"): | |
| url = "https://api.cloudflare.com/client/v4" + path | |
| data = None | |
| if body is not None: | |
| data = body if isinstance(body, bytes) else json.dumps(body).encode() | |
| req = urllib.request.Request(url, data=data, method=method, | |
| headers={"Authorization": "Bearer " + TOKEN, "Content-Type": content_type}) | |
| try: | |
| with urllib.request.urlopen(req, timeout=15) as r: | |
| return json.loads(r.read()) | |
| except urllib.error.HTTPError as e: | |
| try: return json.loads(e.read()) | |
| except: return {"success": False, "error": str(e)} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| # 1. Get account ID | |
| accounts = cf_req("GET", "/accounts") | |
| if not accounts.get("result"): | |
| sys.stderr.write("[boot] CF: could not get account: " + str(accounts) + "\n") | |
| sys.exit(0) | |
| account_id = accounts["result"][0]["id"] | |
| sys.stderr.write("[boot] CF account: " + account_id[:8] + "...\n") | |
| # 2. Stable worker name from hostname | |
| hostname = SPACE | |
| if not hostname: | |
| try: hostname = socket.getfqdn() | |
| except: hostname = "hermes-space" | |
| worker_name = re.sub(r"[^a-z0-9-]", "-", hostname.lower())[:50] + "-tgproxy" | |
| worker_name = re.sub(r"-+", "-", worker_name).strip("-") | |
| sys.stderr.write("[boot] Worker name: " + worker_name + "\n") | |
| # 3. Worker script β Service Worker format, plain JS, no ES modules | |
| # File download path fix: Telegram's file API requires /file/bot<token>/<type>/<file> | |
| # but python-telegram-bot requests /bot<token>/<type>/<file> (no /file/ prefix). | |
| # The regex detects known file-category path segments and rewrites the path. | |
| # All other API calls (getMe, sendMessage, getUpdates, etc.) pass through unchanged. | |
| script = b"""addEventListener('fetch', function(event) { | |
| event.respondWith(handle(event.request)); | |
| }); | |
| async function handle(request) { | |
| var u = new URL(request.url); | |
| var t = new URL('https://api.telegram.org'); | |
| var fileMatch = u.pathname.match(/^([/]bot[^/]+)[/](documents|photos|videos|video_notes|voice|audio|sticker|animations|thumbnails)[/](.+)$/); | |
| if (fileMatch) { | |
| t.pathname = '/file' + fileMatch[1] + '/' + fileMatch[2] + '/' + fileMatch[3]; | |
| } else { | |
| t.pathname = u.pathname; | |
| } | |
| t.search = u.search; | |
| var init = { method: request.method, headers: request.headers, redirect: 'follow' }; | |
| if (request.method !== 'GET' && request.method !== 'HEAD') { init.body = request.body; } | |
| try { | |
| var r = await fetch(t.toString(), init); | |
| var h = new Headers(r.headers); | |
| h.set('Access-Control-Allow-Origin', '*'); | |
| return new Response(r.body, { status: r.status, headers: h }); | |
| } catch(e) { | |
| return new Response(JSON.stringify({ok:false,error:e.message}), | |
| {status:502, headers:{'Content-Type':'application/json'}}); | |
| } | |
| } | |
| """ | |
| # 4. Deploy with plain application/javascript β no multipart, no module metadata | |
| deploy = cf_req("PUT", | |
| "/accounts/" + account_id + "/workers/scripts/" + worker_name, | |
| body=script, content_type="application/javascript") | |
| if not deploy.get("success"): | |
| sys.stderr.write("[boot] Worker deploy failed: " + json.dumps(deploy)[:300] + "\n") | |
| sys.exit(0) | |
| sys.stderr.write("[boot] Worker deployed successfully\n") | |
| # 5. Enable workers.dev subdomain route (idempotent) | |
| cf_req("POST", | |
| "/accounts/" + account_id + "/workers/scripts/" + worker_name + "/subdomain", | |
| body={"enabled": True}) | |
| # 6. Get subdomain with retries | |
| subdomain = "" | |
| for _ in range(3): | |
| r = cf_req("GET", "/accounts/" + account_id + "/workers/subdomain") | |
| subdomain = ((r.get("result") or {}).get("subdomain") or "").strip() | |
| if subdomain: break | |
| time.sleep(3) | |
| if subdomain: | |
| proxy_url = "https://" + worker_name + "." + subdomain + ".workers.dev" | |
| sys.stderr.write("[boot] Proxy URL: " + proxy_url + "\n") | |
| print(proxy_url, end="") | |
| else: | |
| sys.stderr.write("[boot] Worker deployed but subdomain unavailable.\n") | |
| sys.stderr.write("[boot] Set TELEGRAM_API_BASE manually in Space secrets.\n") | |
| PYEOF | |
| ) | |
| if [ -n "$TELEGRAM_PROXY_URL" ]; then | |
| echo "[boot] β Cloudflare proxy deployed: ${TELEGRAM_PROXY_URL}" | |
| else | |
| echo "[boot] β οΈ Cloudflare Worker deploy failed β check errors above β falling back to direct" | |
| fi | |
| fi | |
| # ββ Mode C: Connectivity check and final status βββββββββββββββ | |
| if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then | |
| if [ -n "$TELEGRAM_PROXY_URL" ]; then | |
| echo "[boot] π Testing Telegram proxy connectivity..." | |
| TG_TEST_URL="${TELEGRAM_PROXY_URL}/bot${TELEGRAM_BOT_TOKEN}/getMe" | |
| if curl -s --connect-timeout 8 "$TG_TEST_URL" | python3 -c "import sys,json; d=json.load(sys.stdin); print('ok' if d.get('ok') else 'fail')" 2>/dev/null | grep -q "ok"; then | |
| echo "[boot] β Telegram proxy working β bot is reachable" | |
| else | |
| echo "[boot] β οΈ Telegram proxy test inconclusive β check gateway logs after startup" | |
| fi | |
| else | |
| echo "[boot] π No proxy configured β testing direct Telegram connection..." | |
| if curl -s -I --connect-timeout 8 "https://api.telegram.org" > /dev/null 2>&1; then | |
| echo "[boot] β Direct Telegram connection OK" | |
| else | |
| echo "[boot] β api.telegram.org UNREACHABLE from this Space" | |
| echo "[boot] β Add CLOUDFLARE_WORKERS_TOKEN secret to fix this permanently" | |
| echo "[boot] β Or set TELEGRAM_API_BASE to an existing proxy URL" | |
| fi | |
| fi | |
| fi | |
| # ββ Persist proxy URL to .env and export for gateway startup ββ | |
| # This runs synchronously so the gateway starts with the correct URL already set. | |
| if [ -n "$TELEGRAM_PROXY_URL" ]; then | |
| sed -i "/^TELEGRAM_API_BASE=/d" "${HERMES_HOME:-/data}/.env" 2>/dev/null || true | |
| echo "TELEGRAM_API_BASE=${TELEGRAM_PROXY_URL}" >> "${HERMES_HOME:-/data}/.env" | |
| export TELEGRAM_API_BASE="$TELEGRAM_PROXY_URL" | |
| echo "[boot-tg] β TELEGRAM_API_BASE exported for gateway: ${TELEGRAM_PROXY_URL}" | |
| else | |
| # Use whatever was already in env (e.g. set as a Space secret directly) | |
| export TELEGRAM_API_BASE="${TELEGRAM_API_BASE:-}" | |
| fi | |
| # ββ 8. CONFIGURATION ENFORCEMENT ββββββββββββββββββββββββββββββ | |
| CFG="$HH/config.yaml" | |
| if [ ! -f "$CFG" ]; then | |
| echo "[boot] β Writing config.yaml..." | |
| if [ -n "${OPENROUTER_API_KEY:-}" ]; then PROVIDER="openrouter" | |
| elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then PROVIDER="anthropic" | |
| elif [ -n "${OPENAI_API_KEY:-}" ]; then PROVIDER="openai" | |
| else PROVIDER="auto"; fi | |
| MODEL="${HERMES_MODEL:-openai/gpt-4o-mini}" | |
| cat > "$CFG" << YAML | |
| model: | |
| provider: ${PROVIDER} | |
| default: "${MODEL}" | |
| # ββ BROWSER: container-safe settings ββββββββββββββββββββββββββ | |
| # command_timeout raised from default 30s to 90s to survive slow page loads. | |
| # The chromium_args block passes the no-sandbox flags directly to the Hermes | |
| # browser layer in addition to the AGENT_BROWSER_CHROME_FLAGS env variable, | |
| # so both code paths (agent-browser CLI and Hermes's own Playwright launcher) | |
| # get the flags. | |
| browser: | |
| command_timeout: 90 | |
| inactivity_timeout: 120 | |
| chromium_args: | |
| - --no-sandbox | |
| - --disable-dev-shm-usage | |
| - --disable-gpu | |
| - --disable-setuid-sandbox | |
| - --disable-software-rasterizer | |
| - --disable-blink-features=AutomationControlled | |
| - --window-size=1920,1080 | |
| - --lang=en-US | |
| - --no-first-run | |
| - --no-default-browser-check | |
| YAML | |
| else | |
| # ββ BROWSER FIX: If config.yaml already exists (restored from bucket) but | |
| # lacks the browser block, append it. This ensures upgrades and restored | |
| # workspaces also get the fix without wiping existing user settings. | |
| if ! grep -q "^browser:" "$CFG" 2>/dev/null; then | |
| echo "[boot] π§ Appending browser config block to existing config.yaml..." | |
| cat >> "$CFG" << YAML | |
| # ββ BROWSER: container-safe settings (auto-appended by bootloader) ββββββββββββ | |
| browser: | |
| command_timeout: 90 | |
| inactivity_timeout: 120 | |
| chromium_args: | |
| - --no-sandbox | |
| - --disable-dev-shm-usage | |
| - --disable-gpu | |
| - --disable-setuid-sandbox | |
| - --disable-software-rasterizer | |
| - --disable-blink-features=AutomationControlled | |
| - --window-size=1920,1080 | |
| - --lang=en-US | |
| - --no-first-run | |
| - --no-default-browser-check | |
| YAML | |
| fi | |
| fi | |
| # ββ 9. START SERVICES βββββββββββββββββββββββββββββββββββββββββ | |
| cd "$HH" | |
| echo "[boot] π Starting Hermes Gateway (port 8642)..." | |
| API_SERVER_ENABLED=true \ | |
| API_SERVER_HOST=127.0.0.1 \ | |
| API_SERVER_KEY="${API_SERVER_KEY:-local-dev-key}" \ | |
| TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" \ | |
| TELEGRAM_ALLOWED_USERS="${TELEGRAM_ALLOWED_USERS:-}" \ | |
| TELEGRAM_API_BASE="${TELEGRAM_API_BASE:-}" \ | |
| OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" \ | |
| OPENAI_API_KEY="${OPENAI_API_KEY:-}" \ | |
| ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ | |
| AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| AGENT_BROWSER_CHROME_FLAGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| PLAYWRIGHT_BROWSERS_PATH="/opt/hermes/.playwright" \ | |
| AGENT_BROWSER_INIT_SCRIPT="/opt/hermes-stealth/stealth-init.js" \ | |
| hermes gateway run > "$HH/logs/gateway.log" 2>&1 & | |
| echo "[boot] ποΈ Starting Hermes Dashboard (port 9119)..." | |
| ( | |
| unset TELEGRAM_BOT_TOKEN | |
| hermes dashboard \ | |
| --host 0.0.0.0 \ | |
| --port 9119 \ | |
| --insecure \ | |
| --no-open \ | |
| > "$HH/logs/dashboard.log" 2>&1 | |
| ) & | |
| # ββ Helper: start WebUI as a function so the watchdog can call it repeatedly ββ | |
| start_webui() { | |
| cd /opt/hermes-webui | |
| export HERMES_WEBUI_PASSWORD="" | |
| export WEBUI_PASSWORD="" | |
| export PASSWORD="" | |
| export ADMIN_PASSWORD="" | |
| export AUTH_ENABLED="false" | |
| export PASSWORD_ENABLED="false" | |
| HERMES_WEBUI_AGENT_DIR=/opt/hermes \ | |
| HERMES_API_KEY="${API_SERVER_KEY:-local-dev-key}" \ | |
| python3 server.py >> "$HH/logs/webui.log" 2>&1 | |
| } | |
| echo "[boot] π Starting Hermes WebUI (port 8787)..." | |
| start_webui & | |
| WEBUI_PID=$! | |
| # ββ 10. WAIT FOR BACKENDS TO BIND ββββββββββββββββββββββββββββββ | |
| echo "[boot] β³ Waiting for gateway on :8642..." | |
| for i in $(seq 1 45); do | |
| if curl -sf http://127.0.0.1:8642/health >/dev/null 2>&1; then | |
| echo "[boot] β Gateway is UP!" | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| echo "[boot] β³ Waiting for dashboard on :9119..." | |
| for i in $(seq 1 45); do | |
| if nc -z 127.0.0.1 9119 2>/dev/null; then | |
| echo "[boot] β Dashboard is UP!" | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| echo "[boot] β³ Waiting for WebUI on :8787..." | |
| for i in $(seq 1 45); do | |
| if nc -z 127.0.0.1 8787 2>/dev/null; then | |
| echo "[boot] β WebUI is UP!" | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| # ββ WATCHDOG: auto-restart any crashed service βββββββββββββββββ | |
| # Runs in background, checks every 15 seconds whether each service | |
| # is still listening on its port. If not, restarts it automatically. | |
| # This fixes the ECONNREFUSED flood β WebUI (and others) crash silently | |
| # after startup (OOM, git pull pulling a broken update, etc.) and the | |
| # router has nowhere to forward requests. The watchdog brings them back. | |
| ( | |
| RESTART_DELAY=5 # seconds to wait before restarting after crash | |
| CHECK_INTERVAL=15 # seconds between health checks | |
| while true; do | |
| sleep $CHECK_INTERVAL | |
| # ββ Watch WebUI :8787 ββββββββββββββββββββββββββββββββββββββ | |
| if ! nc -z 127.0.0.1 8787 2>/dev/null; then | |
| echo "[watchdog] β οΈ WebUI (8787) is DOWN β restarting in ${RESTART_DELAY}s..." | |
| sleep $RESTART_DELAY | |
| # Pull latest WebUI code before restarting β catches cases where a bad | |
| # commit caused the crash and upstream has already pushed a fix | |
| (cd /opt/hermes-webui && git pull >/dev/null 2>&1) || true | |
| start_webui & | |
| WEBUI_PID=$! | |
| sleep 8 | |
| if nc -z 127.0.0.1 8787 2>/dev/null; then | |
| echo "[watchdog] β WebUI restarted successfully" | |
| else | |
| echo "[watchdog] β WebUI restart failed β check $HH/logs/webui.log" | |
| # Log the last 20 lines of webui log for diagnosis | |
| tail -20 "$HH/logs/webui.log" | sed 's/^/[watchdog] /' || true | |
| fi | |
| fi | |
| # ββ Watch Gateway :8642 ββββββββββββββββββββββββββββββββββββ | |
| # Use HTTP /health endpoint β nc on 127.0.0.1:8642 gives false negatives | |
| # because the gateway takes time to bind AND can be alive but not yet | |
| # serving. curl /health is the authoritative liveness signal. | |
| if ! curl -sf http://127.0.0.1:8642/health >/dev/null 2>&1; then | |
| echo "[watchdog] β οΈ Gateway (8642) is DOWN β restarting in ${RESTART_DELAY}s..." | |
| sleep $RESTART_DELAY | |
| # --replace atomically stops any existing gateway process before starting | |
| # the new one β prevents the "Gateway already running (PID XXXX)" error | |
| # that caused the infinite restart loop. | |
| API_SERVER_ENABLED=true \ | |
| API_SERVER_HOST=127.0.0.1 \ | |
| API_SERVER_KEY="${API_SERVER_KEY:-local-dev-key}" \ | |
| TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" \ | |
| TELEGRAM_ALLOWED_USERS="${TELEGRAM_ALLOWED_USERS:-}" \ | |
| TELEGRAM_API_BASE="${TELEGRAM_API_BASE:-}" \ | |
| OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" \ | |
| OPENAI_API_KEY="${OPENAI_API_KEY:-}" \ | |
| ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ | |
| PLAYWRIGHT_BROWSERS_PATH="/opt/hermes/.playwright" \ | |
| AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| AGENT_BROWSER_CHROME_FLAGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer,--disable-blink-features=AutomationControlled,--window-size=1920,1080,--lang=en-US,--no-first-run,--no-default-browser-check" \ | |
| AGENT_BROWSER_INIT_SCRIPT="/opt/hermes-stealth/stealth-init.js" \ | |
| hermes gateway run --replace >> "$HH/logs/gateway.log" 2>&1 & | |
| sleep 10 | |
| if curl -sf http://127.0.0.1:8642/health >/dev/null 2>&1; then | |
| echo "[watchdog] β Gateway restarted successfully" | |
| else | |
| echo "[watchdog] β Gateway restart failed β check $HH/logs/gateway.log" | |
| tail -20 "$HH/logs/gateway.log" | sed 's/^/[watchdog] /' || true | |
| fi | |
| fi | |
| # ββ Watch Dashboard :9119 ββββββββββββββββββββββββββββββββββ | |
| if ! nc -z 127.0.0.1 9119 2>/dev/null; then | |
| echo "[watchdog] β οΈ Dashboard (9119) is DOWN β restarting in ${RESTART_DELAY}s..." | |
| sleep $RESTART_DELAY | |
| ( | |
| unset TELEGRAM_BOT_TOKEN || true | |
| hermes dashboard \ | |
| --host 0.0.0.0 --port 9119 --insecure --no-open \ | |
| >> "$HH/logs/dashboard.log" 2>&1 | |
| ) & | |
| sleep 8 | |
| if nc -z 127.0.0.1 9119 2>/dev/null; then | |
| echo "[watchdog] β Dashboard restarted successfully" | |
| else | |
| echo "[watchdog] β Dashboard restart failed β check $HH/logs/dashboard.log" | |
| tail -10 "$HH/logs/dashboard.log" | sed 's/^/[watchdog] /' || true | |
| fi | |
| fi | |
| done | |
| ) & | |
| # ββ 11. BACKGROUND CLOUD JANITOR & PURGER βββββββββββββββββββββββ | |
| if [ -n "${HF_TOKEN:-}" ] && [ -n "${HF_BUCKET:-}" ]; then | |
| ( | |
| echo "[boot-clean] π§Ό Removing residual dependencies from remote cloud storage..." | |
| hf buckets remove "hf://buckets/${HF_BUCKET}/.venv/" --recursive --yes >/dev/null 2>&1 || true | |
| hf buckets remove "hf://buckets/${HF_BUCKET}/venv/" --recursive --yes >/dev/null 2>&1 || true | |
| hf buckets remove "hf://buckets/${HF_BUCKET}/.cache/" --recursive --yes >/dev/null 2>&1 || true | |
| hf buckets remove "hf://buckets/${HF_BUCKET}/node_modules/" --recursive --yes >/dev/null 2>&1 || true | |
| echo "[boot-clean] π§Ό Remote bucket optimized." | |
| # Wait for gateway to fully initialise its databases before first upload. | |
| # The restore is now synchronous so databases are already on disk, | |
| # but the gateway needs ~15s to open them and apply any migrations. | |
| sleep 20 | |
| while true; do | |
| # Checkpoint WAL files before upload so the .db files on disk are complete | |
| # (WAL mode means writes go to -wal file first; CHECKPOINT flushes them to main .db) | |
| python3 -c " | |
| import os, sqlite3, glob | |
| for db in glob.glob('/data/*.db'): | |
| try: | |
| c = sqlite3.connect(db, timeout=5) | |
| c.execute('PRAGMA wal_checkpoint(PASSIVE);') | |
| c.close() | |
| except: pass | |
| " 2>/dev/null || true | |
| # Upload /data β bucket (local is authoritative β bucket gets overwritten) | |
| # .db files ARE uploaded so databases persist across Space restarts. | |
| # .db-wal/.db-shm/.db-journal are excluded β these are transient SQLite | |
| # files that are invalid outside an active write transaction. | |
| hf sync "$HH" "hf://buckets/${HF_BUCKET}" \ | |
| --delete \ | |
| --exclude ".venv/**" --exclude "**/venv/**" --exclude "venv/**" \ | |
| --exclude ".cache/**" --exclude "**/.cache/**" \ | |
| --exclude "node_modules/**" --exclude "**/node_modules/**" \ | |
| --exclude "__pycache__/**" --exclude "**/__pycache__/**" \ | |
| --exclude "**/*.pyc" --exclude "**/*.log" --exclude "logs/**" --exclude "**/logs/**" \ | |
| --exclude "**/*.db-wal" --exclude "**/*.db-shm" --exclude "**/*.db-journal" \ | |
| >/dev/null 2>&1 || true | |
| sleep "${SYNC_INTERVAL:-60}" | |
| done | |
| ) & | |
| fi | |
| # ββ 12. START ROUTER βββββββββββββββββββββββββββββββββββββββββββ | |
| echo "[boot] π Starting router on port ${PORT:-7860}..." | |
| exec node /opt/router/server.js | |
| ENDSH | |
| RUN chmod +x /opt/start.sh | |
| # ββ Write build-time Kanban patch script safely βββββββββββββββ | |
| RUN cat << 'EOF' > /tmp/patch_kanban.py | |
| import sys | |
| try: | |
| from pathlib import Path | |
| p = Path("/opt/hermes/hermes_cli/kanban_db.py") | |
| if not p.exists(): | |
| print("kanban_db.py not found β skip") | |
| sys.exit(0) | |
| src = p.read_text(encoding="utf-8") | |
| sentinel = "# hf-patch: idempotent" | |
| if sentinel in src: | |
| print("already patched") | |
| sys.exit(0) | |
| import re | |
| patched = re.sub( | |
| r'(conn\.execute\(["\']ALTER TABLE \w+ ADD COLUMN [^)]+\)["\'][ \t]*\))', | |
| r'try:\n \1 ' + sentinel + r'\n except Exception:\n pass', | |
| src | |
| ) | |
| if patched != src: | |
| p.write_text(patched, encoding="utf-8") | |
| print("kanban patch: applied") | |
| else: | |
| print("kanban patch: pattern not found β may already be fixed upstream") | |
| except Exception as e: | |
| print(f"Kanban patch failed silently to avoid interrupting build: {e}") | |
| EOF | |
| RUN python3 /tmp/patch_kanban.py && rm /tmp/patch_kanban.py | |
| # ββ FIX ALL DATABASE FRAGMENTATION & OWNERSHIP & DEFINE SHELL ββ | |
| # Enforces bash as standard terminal shell context for the hermes runtime user. | |
| RUN chsh -s /bin/bash hermes || true \ | |
| && mkdir -p /data /data/logs \ | |
| && mkdir -p /home/hermes \ | |
| && rm -rf /home/hermes/.hermes && ln -sf /data /home/hermes/.hermes \ | |
| && rm -rf /opt/data && ln -sf /data /opt/data \ | |
| && touch /home/hermes/.bashrc \ | |
| && echo "export PATH=\"/opt/hermes/.venv/bin:/opt/data/.local/bin:\$PATH\"" >> /home/hermes/.bashrc \ | |
| # ββ BROWSER FIX: Also export Playwright and Chrome flags in the interactive | |
| # shell so that any terminal commands the agent runs (e.g. npx playwright | |
| # or agent-browser) also inherit the no-sandbox configuration ββββββββββββββ | |
| && echo "export PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright" >> /home/hermes/.bashrc \ | |
| && echo "export AGENT_BROWSER_ARGS=\"--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer\"" >> /home/hermes/.bashrc \ | |
| && echo "export AGENT_BROWSER_CHROME_FLAGS=\"--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--disable-setuid-sandbox,--disable-software-rasterizer\"" >> /home/hermes/.bashrc \ | |
| && chown -R hermes:hermes /data /home/hermes /opt/hermes /opt/hermes-webui /opt/router /opt/start.sh /opt/hermes-stealth \ | |
| && chown -h hermes:hermes /home/hermes/.hermes /opt/data \ | |
| && chmod -R 777 /data \ | |
| # ββ PERMISSION FIX: Ensure the hermes user owns the Playwright browser | |
| # cache we installed as root, and both agent-browser config locations ββββββββ | |
| && chown -R hermes:hermes /opt/hermes/.playwright /opt/hermes/.agent-browser /home/hermes/.agent-browser | |
| EXPOSE 7860 | |
| USER hermes | |
| ENTRYPOINT ["/opt/start.sh"] | |