Human-in-the-loop login window (magic link)

Short-lived remote desktop session to complete interactive logins (X/Twitter, 2FA, CAPTCHA) on a headless VPS. Default TTL: 5 minutes.

Goal

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.

What you get

How it works (high level)

  1. Generate token + one-time password
  2. Start desktop stack: Xvfb + XFCE + x11vnc + websockify/noVNC (localhost only)
  3. Launch Chrome pointed at https://x.com using the same user-data dir as automation
  4. Create a temporary Caddy route file under /etc/caddy/login-magic/<token>.caddy
  5. Auto-cleanup after TTL

Critical gotchas (learned)

Fix 2 (recommended for X): run OpenClaw Chrome headful (non-headless)

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.

1) Create a Chrome wrapper that ensures an X display exists

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

2) Patch OpenClaw config

Edit /root/.openclaw/openclaw.json:

{
  "browser": {
    "headless": false,
    "executablePath": "/usr/local/bin/openclaw-chrome"
  }
}

3) Restart the gateway

openclaw gateway restart

Verify: browser(action="start", profile="openclaw") reports headless=false.

Single install script (fully reproducible)

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

Generator script (for reference)

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