# syntax=docker/dockerfile:1 # A Wayland desktop for an agent: headless Sway, Chromium with the Chrome # DevTools Protocol on loopback, Claude Code with Chrome DevTools MCP, and # noVNC so a human can watch and take over. FROM node:22-trixie-slim RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates chromium chromium-sandbox curl dbus fonts-liberation \ fonts-noto-cjk fonts-noto-color-emoji fonts-noto-core foot git grim jq \ novnc sway wayvnc websockify wtype \ && rm -rf /var/lib/apt/lists/* RUN npm install -g @anthropic-ai/claude-code@2.1.321 chrome-devtools-mcp@1.12.0 \ && npm cache clean --force COPY <<'EOF' /etc/desktop/sway.conf xwayland disable output HEADLESS-1 mode 1600x900 seat seat0 fallback true default_border pixel 2 EOF COPY --chmod=755 <<'EOF' /usr/local/bin/desktop #!/usr/bin/env bash set -euo pipefail export XDG_RUNTIME_DIR=/tmp/runtime-$(id -u) mkdir -p -m 700 "$XDG_RUNTIME_DIR" exec dbus-run-session -- /usr/local/bin/desktop-session EOF COPY --chmod=755 <<'EOF' /usr/local/bin/desktop-session #!/usr/bin/env bash set -euo pipefail trap 'kill $(jobs -p) 2>/dev/null || true' EXIT wait_for() { # wait_for local deadline=$((SECONDS + $1)) what=$2; shift 2 until "$@" >/dev/null 2>&1; do ((SECONDS < deadline)) || { echo "timed out waiting for $what" >&2; exit 1; } sleep 0.2 done } window_open() { swaymsg -t get_tree | jq -e --arg id "$1" '[.. | objects | select(.app_id? == $id)] | length > 0'; } # Headless wlroots with software rendering: no GPU, DRM or input devices needed. export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 sway --config /etc/desktop/sway.conf & wait_for 30 sway bash -c 'compgen -G "$XDG_RUNTIME_DIR/sway-ipc.*.sock" && compgen -G "$XDG_RUNTIME_DIR/wayland-*"' export SWAYSOCK=$(compgen -G "$XDG_RUNTIME_DIR/sway-ipc.*.sock") export WAYLAND_DISPLAY=$(basename "$(compgen -G "$XDG_RUNTIME_DIR/wayland-[0-9]")") # Skip Claude Code's "use this API key?" prompt for the key we were given. if [[ -n ${ANTHROPIC_API_KEY:-} ]]; then jq --arg k "$ANTHROPIC_API_KEY" '.customApiKeyResponses.approved += [$k[-20:]]' \ "$HOME/.claude.json" > /tmp/claude.json && mv /tmp/claude.json "$HOME/.claude.json" fi # The agent's terminal on the left, Chromium on the right. mkdir -p "$HOME/work" foot --app-id agent-terminal --working-directory "$HOME/work" bash -l -c 'claude; exec bash -l' & wait_for 30 "the terminal" window_open agent-terminal chromium --user-data-dir="$HOME/.config/chromium" --ozone-platform=wayland \ --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 \ --disable-gpu --disable-quic --disable-dev-shm-usage --password-store=basic \ --no-first-run --no-default-browser-check about:blank & wait_for 60 Chromium window_open chromium wait_for 30 "the DevTools endpoint" curl -sf http://127.0.0.1:9222/json/version # VNC with a password. relax_encryption enables Apple Diffie-Hellman auth, # which noVNC supports. Only websockify listens beyond loopback, on port 6080. password=${VNC_PASSWORD:-$(head -c 12 /dev/urandom | base64)} mkdir -p -m 700 "$XDG_RUNTIME_DIR/wayvnc" cat > "$XDG_RUNTIME_DIR/wayvnc/config" </dev/tcp/127.0.0.1/5900' [[ -n ${VNC_PASSWORD:-} ]] || echo "noVNC password: $password" mkdir -p /tmp/novnc cp -rs /usr/share/novnc/. /tmp/novnc/ echo ready > /tmp/novnc/readyz websockify --web=/tmp/novnc 6080 127.0.0.1:5900 & wait -n echo "a desktop process exited" >&2 exit 1 EOF USER node ENV HOME=/home/node CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_AUTOUPDATER=1 WORKDIR /home/node/work COPY --chown=node:node <<'EOF' /home/node/.claude/CLAUDE.md # Your desktop You run in a terminal on a Wayland (Sway) desktop. A human may be watching it over VNC. Chromium is open next to you. - Prefer the `chrome-devtools` MCP tools for the web. They drive that same Chromium window, so the human sees what you do. - For anything else on screen, use computer-use commands: - Screenshot: `grim screen.png`, then read it. The screen is 1600x900. - Window positions: `swaymsg -t get_tree | jq '.. | objects | select(.app_id?) | {app_id, rect}'` - Focus a window: `swaymsg '[app_id=chromium] focus'` - Click at x,y: `swaymsg seat seat0 cursor set X Y && swaymsg seat seat0 cursor press button1 && swaymsg seat seat0 cursor release button1` - Type: `wtype 'text'`; press a key: `wtype -k Return` EOF COPY --chown=node:node <<'EOF' /home/node/.claude/settings.json { "permissions": { "allow": [ "mcp__chrome-devtools", "Read(//tmp/**)", "Bash(grim:*)", "Bash(swaymsg:*)", "Bash(wtype:*)" ] } } EOF RUN claude mcp add --scope user chrome-devtools -- \ chrome-devtools-mcp --browserUrl http://127.0.0.1:9222 --no-usage-statistics --no-performance-crux \ && jq '.hasCompletedOnboarding = true | .theme = "dark" | .projects["/home/node/work"].hasTrustDialogAccepted = true' \ ~/.claude.json > /tmp/claude.json && mv /tmp/claude.json ~/.claude.json EXPOSE 6080 ENTRYPOINT ["/usr/local/bin/desktop"]