# ============================================================
# 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"]