| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| FROM nousresearch/hermes-agent:latest |
| |
| USER root |
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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"] |
| |