# ============================================================ # 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( '' + 'Starting…' + '' + '
⚙️
' + '

Hermes is starting…

' + '

This page refreshes automatically every 5 seconds

' + '
' ); } }); 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 ? '
⚠ ' + errMsg + '
' : ''; return '' + '' + 'Hermes — Sign in' + '
' + '
🥯
' + '

Hermes Agent

' + '

Enter your gateway token to access the workspace.

' + errHtml + '
' + '' + '' + '' + '
' + '

Set GATEWAY_TOKEN in your Space secrets to activate this gate.

' + '
'; } 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// # but python-telegram-bot requests /bot// (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"]