Short-lived remote desktop session to complete interactive logins (X/Twitter, 2FA, CAPTCHA) on a headless VPS. Default TTL: 5 minutes.
Allow the user to complete an interactive login from any device (including mobile) by opening a short-lived link; after TTL the session is torn down.
https://login.<your-domain>/<token>/vnc.htmllogin + one-time password127.0.0.1; only Caddy is publictoken + one-time passwordhttps://x.com using the same user-data dir as automation/etc/caddy/login-magic/<token>.caddy/websockify (absolute path) — proxy that too.--user-data-dir./websockify is shared and ports are fixed, support one active login window at a time (clean old route files first).X/Twitter is noticeably more cooperative when automation runs headful (even if it’s still “virtual” via Xvfb). This also enables a clean human-in-the-loop fallback when a captcha/2FA blocks posting.
Create /usr/local/bin/openclaw-chrome:
#!/usr/bin/env bash
set -euo pipefail
# Wrapper to ensure OpenClaw can run Chrome in headful mode on servers without a physical display.
# Starts/uses Xvfb on DISPLAY=:99.
DISPLAY_NUM=":99"
XVFB_BIN="/usr/bin/Xvfb"
CHROME_BIN="/usr/bin/google-chrome"
export DISPLAY="$DISPLAY_NUM"
# Start Xvfb if not running
if ! pgrep -f "^${XVFB_BIN} ${DISPLAY_NUM}\\b" >/dev/null 2>&1; then
rm -f /tmp/.X99-lock || true
nohup "$XVFB_BIN" "$DISPLAY_NUM" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset >/tmp/openclaw-xvfb.log 2>&1 &
sleep 0.2
fi
exec "$CHROME_BIN" "$@"
Then:
sudo chmod +x /usr/local/bin/openclaw-chrome
Edit /root/.openclaw/openclaw.json:
{
"browser": {
"headless": false,
"executablePath": "/usr/local/bin/openclaw-chrome"
}
}
openclaw gateway restart
Verify: browser(action="start", profile="openclaw") reports headless=false.
Run this on a fresh Ubuntu/Debian VPS (as root). It installs packages, patches Caddy, creates folders, and writes the generator script.
#!/usr/bin/env bash
set -euo pipefail
# install_hil_login_window.sh
#
# Installs the Human-in-the-loop (HIL) magic-link login window on an Ubuntu/Debian VPS.
# - Installs desktop + VNC + noVNC deps
# - Adds/updates Caddyfile blocks for login.<domain>
# - Creates required directories + fallback pages
# - Installs /root/.openclaw/workspace/scripts/magic_login_window.sh generator
#
# Usage:
# sudo bash install_hil_login_window.sh --domain newclaw.xyz \
# --login-subdomain login \
# --workspace /root/.openclaw/workspace
#
# Then generate a window:
# /root/.openclaw/workspace/scripts/magic_login_window.sh --ttl 300
DOMAIN=""
LOGIN_SUBDOMAIN="login"
WORKSPACE="/root/.openclaw/workspace"
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="${2:-}"; shift 2 ;;
--login-subdomain) LOGIN_SUBDOMAIN="${2:-}"; shift 2 ;;
--workspace) WORKSPACE="${2:-}"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ -n "$DOMAIN" ]] || { echo "Missing --domain <example.com>" >&2; exit 2; }
LOGIN_HOST="$LOGIN_SUBDOMAIN.$DOMAIN"
# 1) Install packages
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y xvfb xfce4 xfce4-goodies x11vnc novnc websockify dbus-x11 openssl
# 2) Create directories
mkdir -p /etc/caddy/login-magic
mkdir -p "$WORKSPACE/scripts"
mkdir -p "$WORKSPACE/$LOGIN_HOST"
# Fallback page (login host)
cat >"$WORKSPACE/$LOGIN_HOST/index.html" <<'HTML'
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login Window</title></head><body><h1>No active login windows</h1><p>If you were expecting a magic link, it may have expired.</p></body></html>
HTML
# 3) Install generator script
cat >"$WORKSPACE/scripts/magic_login_window.sh" <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
# Creates a short-lived noVNC remote desktop behind Caddy.
# Usage:
# magic_login_window.sh --ttl 300
# Prints the URL + basic auth creds.
TTL=300
while [[ $# -gt 0 ]]; do
case "$1" in
--ttl) TTL="${2:-}"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
DOMAIN="__LOGIN_HOST__"
LOGIN_USER="login"
TOKEN="$(openssl rand -hex 16)"
PASS="$(openssl rand -base64 24 | tr -d '\n' | tr '/+' 'ab' | cut -c1-24)"
PASS_HASH="$(caddy hash-password --plaintext "$PASS")"
# ports (local only)
DISPLAY_NUM=99
VNC_PORT=5901
NOVNC_PORT=6080
# paths
ROUTE_DIR="/etc/caddy/login-magic"
ROUTE_FILE="$ROUTE_DIR/$TOKEN.caddy"
STATE_DIR="/tmp/openclaw-magic-login/$TOKEN"
mkdir -p "$ROUTE_DIR" "$STATE_DIR"
# Only one active window at a time (we use fixed ports).
# Remove any old routes so /websockify auth doesn't mismatch.
rm -f "$ROUTE_DIR"/*.caddy >/dev/null 2>&1 || true
# Stop any previous stack on these fixed ports (best-effort)
for p in websockify x11vnc Xvfb xfce4-session startxfce4 google-chrome chrome; do
pkill -f "$p.*:99" >/dev/null 2>&1 || true
pkill -f "$p.*$NOVNC_PORT" >/dev/null 2>&1 || true
pkill -f "$p.*$VNC_PORT" >/dev/null 2>&1 || true
done
# Start virtual display + XFCE
Xvfb :$DISPLAY_NUM -screen 0 1280x720x24 -ac +extension RANDR >"$STATE_DIR/xvfb.log" 2>&1 &
XVFB_PID=$!
export DISPLAY=:$DISPLAY_NUM
sleep 0.5
startxfce4 >"$STATE_DIR/xfce.log" 2>&1 &
XFCE_PID=$!
x11vnc -display :$DISPLAY_NUM -localhost -nopw -forever -shared -rfbport $VNC_PORT >"$STATE_DIR/x11vnc.log" 2>&1 &
VNC_PID=$!
websockify --web=/usr/share/novnc 127.0.0.1:$NOVNC_PORT 127.0.0.1:$VNC_PORT >"$STATE_DIR/websockify.log" 2>&1 &
WS_PID=$!
# Prepare an easy click-to-open Chrome launcher on the desktop
mkdir -p /root/Desktop
cat >/root/Desktop/Chrome.desktop <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=Chrome (Login)
Exec=google-chrome --no-sandbox --disable-dev-shm-usage --user-data-dir=/root/.openclaw/browser/openclaw/user-data https://x.com
Icon=google-chrome
Terminal=false
Categories=Network;WebBrowser;
DESKTOP
chmod +x /root/Desktop/Chrome.desktop || true
command -v gio >/dev/null 2>&1 && gio set /root/Desktop/Chrome.desktop metadata::trusted true || true
# Launch Chrome in that display using OpenClaw's user-data dir so cookies persist.
# IMPORTANT: do not run two Chrome instances against the same user-data-dir.
CHROME_PROFILE_DIR="/root/.openclaw/browser/openclaw/user-data"
mkdir -p "$CHROME_PROFILE_DIR"
pkill -f "google-chrome.*--user-data-dir=$CHROME_PROFILE_DIR" >/dev/null 2>&1 || true
pkill -f "$CHROME_PROFILE_DIR" >/dev/null 2>&1 || true
rm -f "$CHROME_PROFILE_DIR/SingletonLock" "$CHROME_PROFILE_DIR/SingletonSocket" "$CHROME_PROFILE_DIR/SingletonCookie" >/dev/null 2>&1 || true
nohup env DISPLAY=:$DISPLAY_NUM google-chrome --user-data-dir="$CHROME_PROFILE_DIR" --no-sandbox --disable-dev-shm-usage "https://x.com" >"$STATE_DIR/chrome.log" 2>&1 &
CHROME_PID=$!
cat >"$ROUTE_FILE" <<CADDY
handle_path /$TOKEN/* {
basicauth {
$LOGIN_USER $PASS_HASH
}
header Cache-Control "no-store"
reverse_proxy 127.0.0.1:$NOVNC_PORT
}
handle /websockify* {
basicauth {
$LOGIN_USER $PASS_HASH
}
header Cache-Control "no-store"
reverse_proxy 127.0.0.1:$NOVNC_PORT
}
CADDY
caddy validate --config /etc/caddy/Caddyfile >/dev/null
systemctl reload caddy
(
sleep "$TTL"
rm -f "$ROUTE_FILE" || true
systemctl reload caddy || true
kill "$CHROME_PID" "$WS_PID" "$VNC_PID" "$XFCE_PID" "$XVFB_PID" >/dev/null 2>&1 || true
rm -rf "$STATE_DIR" >/dev/null 2>&1 || true
) >/dev/null 2>&1 &
URL="https://$DOMAIN/$TOKEN/vnc.html"
cat <<OUT
Magic login window is READY (expires in ~${TTL}s)
URL: $URL
Username: $LOGIN_USER
Password: $PASS
OUT
BASH
# Replace placeholder with real login host
sed -i "s/__LOGIN_HOST__/$LOGIN_HOST/g" "$WORKSPACE/scripts/magic_login_window.sh"
chmod +x "$WORKSPACE/scripts/magic_login_window.sh"
# 4) Patch Caddyfile
CADDYFILE="/etc/caddy/Caddyfile"
LOGIN_BLOCK_START="# BEGIN openclaw:hil-login $LOGIN_HOST"
LOGIN_BLOCK_END="# END openclaw:hil-login $LOGIN_HOST"
# Ensure login block exists
if ! grep -qF "$LOGIN_BLOCK_START" "$CADDYFILE"; then
cat >>"$CADDYFILE" <<CADDY
$LOGIN_BLOCK_START
$LOGIN_HOST {
import /etc/caddy/login-magic/*.caddy
root * $WORKSPACE/$LOGIN_HOST
file_server
}
$LOGIN_BLOCK_END
CADDY
fi
caddy validate --config "$CADDYFILE"
systemctl restart caddy
cat <<OUT
Installed HIL login window.
Login host: https://$LOGIN_HOST/
Next:
$WORKSPACE/scripts/magic_login_window.sh --ttl 300
OUT
This is the script the installer writes. You can also manage it directly.
#!/usr/bin/env bash
set -euo pipefail
# Creates a short-lived noVNC remote desktop behind Caddy.
# Usage:
# magic_login_window.sh --ttl 120
# Prints the URL + basic auth creds.
TTL=300
while [[ $# -gt 0 ]]; do
case "$1" in
--ttl) TTL="${2:-}"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
DOMAIN="login.newclaw.xyz"
LOGIN_USER="login"
TOKEN="$(openssl rand -hex 16)"
PASS="$(openssl rand -base64 24 | tr -d '\n' | tr '/+' 'ab' | cut -c1-24)"
PASS_HASH="$(caddy hash-password --plaintext "$PASS")"
# ports (local only)
DISPLAY_NUM=99
VNC_PORT=5901
NOVNC_PORT=6080
# paths
ROUTE_DIR="/etc/caddy/login-magic"
ROUTE_FILE="$ROUTE_DIR/$TOKEN.caddy"
STATE_DIR="/tmp/openclaw-magic-login/$TOKEN"
mkdir -p "$ROUTE_DIR" "$STATE_DIR"
# Only one active window at a time (we use fixed ports).
# Remove any old routes so /websockify auth doesn't mismatch.
rm -f "$ROUTE_DIR"/*.caddy >/dev/null 2>&1 || true
# Stop any previous stack on these fixed ports (best-effort)
# (We keep it simple for v0; later we can allocate ports per session.)
for p in websockify x11vnc Xvfb xfce4-session startxfce4 google-chrome chrome; do
pkill -f "$p.*:99" >/dev/null 2>&1 || true
pkill -f "$p.*$NOVNC_PORT" >/dev/null 2>&1 || true
pkill -f "$p.*$VNC_PORT" >/dev/null 2>&1 || true
done
# Start virtual display + XFCE
Xvfb :$DISPLAY_NUM -screen 0 1280x720x24 -ac +extension RANDR >"$STATE_DIR/xvfb.log" 2>&1 &
XVFB_PID=$!
export DISPLAY=:$DISPLAY_NUM
# Give X a moment
sleep 0.5
# Start XFCE session
startxfce4 >"$STATE_DIR/xfce.log" 2>&1 &
XFCE_PID=$!
# Start VNC server bound to localhost only (no password; HTTP basic auth is enforced by Caddy)
x11vnc -display :$DISPLAY_NUM -localhost -nopw -forever -shared -rfbport $VNC_PORT >"$STATE_DIR/x11vnc.log" 2>&1 &
VNC_PID=$!
# Start noVNC/websockify bound to localhost only
# novnc utils live at /usr/share/novnc
websockify --web=/usr/share/novnc 127.0.0.1:$NOVNC_PORT 127.0.0.1:$VNC_PORT >"$STATE_DIR/websockify.log" 2>&1 &
WS_PID=$!
# Prepare an easy click-to-open Chrome launcher on the desktop
mkdir -p /root/Desktop
cat >/root/Desktop/Chrome.desktop <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=Chrome (Login)
Exec=google-chrome --no-sandbox --disable-dev-shm-usage --user-data-dir=/root/.openclaw/browser/openclaw/user-data https://x.com
Icon=google-chrome
Terminal=false
Categories=Network;WebBrowser;
DESKTOP
chmod +x /root/Desktop/Chrome.desktop || true
# Mark launcher trusted for XFCE (prevents "Untrusted application launcher")
command -v gio >/dev/null 2>&1 && gio set /root/Desktop/Chrome.desktop metadata::trusted true || true
# Launch Chrome in that display using OpenClaw's user-data dir so cookies persist.
# IMPORTANT: do not run two Chrome instances against the same user-data-dir.
CHROME_PROFILE_DIR="/root/.openclaw/browser/openclaw/user-data"
mkdir -p "$CHROME_PROFILE_DIR"
# Best-effort: stop any existing Chrome using that profile to avoid SingletonLock aborts.
# (This will temporarily interrupt browser automation while the login window is active.)
pkill -f "google-chrome.*--user-data-dir=$CHROME_PROFILE_DIR" >/dev/null 2>&1 || true
pkill -f "$CHROME_PROFILE_DIR" >/dev/null 2>&1 || true
rm -f "$CHROME_PROFILE_DIR/SingletonLock" "$CHROME_PROFILE_DIR/SingletonSocket" "$CHROME_PROFILE_DIR/SingletonCookie" >/dev/null 2>&1 || true
# Launch Chrome on this X display. (Do NOT pass a --display flag; use DISPLAY env.)
nohup env DISPLAY=:$DISPLAY_NUM google-chrome --user-data-dir="$CHROME_PROFILE_DIR" --no-sandbox --disable-dev-shm-usage "https://x.com" >"$STATE_DIR/chrome.log" 2>&1 &
CHROME_PID=$!
# Write a temporary Caddy route for this token.
# handle_path strips /<token> prefix so upstream sees /vnc.html etc.
cat >"$ROUTE_FILE" <<CADDY
# Token-scoped route (works when client uses relative paths under /<token>/...)
handle_path /$TOKEN/* {
basicauth {
$LOGIN_USER $PASS_HASH
}
header Cache-Control "no-store"
reverse_proxy 127.0.0.1:$NOVNC_PORT
}
# noVNC's stock vnc.html often connects to /websockify (absolute path).
# Since we run only ONE active window at a time (fixed ports), we also proxy
# /websockify for the current session, protected by the same auth.
handle /websockify* {
basicauth {
$LOGIN_USER $PASS_HASH
}
header Cache-Control "no-store"
reverse_proxy 127.0.0.1:$NOVNC_PORT
}
CADDY
# Reload Caddy config to pick up the new route.
caddy validate --config /etc/caddy/Caddyfile >/dev/null
systemctl reload caddy
# Schedule cleanup
(
sleep "$TTL"
rm -f "$ROUTE_FILE" || true
systemctl reload caddy || true
kill "$CHROME_PID" "$WS_PID" "$VNC_PID" "$XFCE_PID" "$XVFB_PID" >/dev/null 2>&1 || true
rm -rf "$STATE_DIR" >/dev/null 2>&1 || true
) >/dev/null 2>&1 &
URL="https://$DOMAIN/$TOKEN/vnc.html"
cat <<OUT
Magic login window is READY (expires in ~${TTL}s)
URL: $URL
Username: $LOGIN_USER
Password: $PASS
Tip: open the link, then log into X/Twitter in the remote Chrome window.
OUT
Markdown version: human-in-the-loop-login.md