Files
excalidraw-mcp-sentinel/docs/HANDOFF-agent-setup.md
T
forkless bb710b46b0
Docker Build (ARM64) / Build & Push (push) Successful in 2m4s
fix: suppress CORS flood from excalidraw.com/og-image-3.png fetch
2026-06-25 00:52:35 +02:00

8.7 KiB
Raw Blame History

Agent Handoff: ARM64 Docker Build & Collaboration Setup

Overview

Gitea server at gitea.forkless.com (RPi 5, native ARM64) hosts the repo forkless/excalidraw-mcp-sentinel. An ARM64 Gitea Actions runner is registered on the Gitea server itself (labels: arm64, ubuntu-latest). Runner #1.

Local workspace: /home/pe1085/development/excalidraw-mcp-sentinel Remote: https://gitea.forkless.com/forkless/excalidraw-mcp-sentinel


What Was Done

1. Gitea Actions Workflow

.gitea/workflows/docker.yml — builds both Docker images on ARM64 runner:

  • gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest
  • gitea.forkless.com/forkless/excalidraw-mcp-sentinel-canvas:latest

Triggers on push to main (paths: Dockerfile, src/, frontend/, .gitea/**), PRs, and workflow_dispatch. Avoid empty commits — they don't match the paths filter and won't trigger a build.

2. Alpine Base Images

Both Dockerfile and Dockerfile.canvas changed from node:20-slim to node:20-alpine. Build tools via apk add --no-cache python3 make g++.

3. AMD64 Runner Removed

There was a local x86_64 Gitea Actions runner (wsl-runner, id=1) on this machine that competed with the ARM64 runner. It was stopped and its .runner file deleted from /home/pe1085/development/gitea-test/. It had ubuntu-latest:docker://catthehacker/ubuntu:full-latest — would build amd64 images that can't run on ARM64. Do not re-register it. Only the RPi 5 ARM64 runner should exist.

4. Frontend WebSocket URL — CUSTOM_VITE_WS_URL

New env var CUSTOM_VITE_WS_URL controls the frontend's WebSocket target. Hardcoded wss://excalidraw.forkless.com replaced with a Vite build-time define.

Where:

  • vite.config.js — reads process.env.CUSTOM_VITE_WS_URL, defaults to wss://excalidraw.forkless.com, exposes as import.meta.env.CUSTOM_VITE_WS_URL
  • frontend/src/App.tsx — uses import.meta.env.CUSTOM_VITE_WS_URL

URL normalization (auto handles user input):

  • excalidraw.forkless.comwss://excalidraw.forkless.com
  • wss://excalidraw.forkless.com/socket.io/foowss://excalidraw.forkless.com
  • ws://dev.local:5173ws://dev.local:5173 (preserves ws://)
  • https://example.com:3002/pathwss://example.com:3002
  • 10.0.101.100wss://10.0.101.100
  • [::1]:3000wss://[::1]:3000
  • Port is preserved if present, omitted if absent.

5. .env.example

Created at repo root. Documents all key env vars.

CUSTOM_VITE_WS_URL=excalidraw.forkless.com   # auto-normalized
CANVAS_PORT=3000
HOST=0.0.0.0
EXCALIDRAW_API_KEY=
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
EXCALIDRAW_DB_PATH=~/.excalidraw-mcp/excalidraw.db
LOG_LEVEL=info

6. Docker Build Integration

Dockerfile.canvas frontend-builder stage accepts ARG CUSTOM_VITE_WS_URL and sets it as ENV so Vite picks it up at build time.

docker-compose.yml canvas service passes it as build arg:

args:
  - CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com}

To override at build time:

docker compose build --build-arg CUSTOM_VITE_WS_URL=wss://my-server.com canvas
# or
CUSTOM_VITE_WS_URL=my-server.com docker compose build canvas

7. Reverse Proxy Config (NPM v2.15.1 / OpenResty)

The server at excalidraw.forkless.com runs behind Nginx Proxy Manager. Critical settings for WebSocket to work:

  • WebSockets Support toggle enabled in NPM
  • The WebSocket upgrade headers (Upgrade, Connection) are forwarded
  • Do NOT add custom nginx proxy config in the Advanced tab unless the toggle alone doesn't work — the toggle handles it. Custom config can conflict and break NPM entirely (requires container restart to recover).

8. trust proxy in Express

src/server.ts line 43:

app.set('trust proxy', 1);

Required because NPM sets X-Forwarded-For and express-rate-limit rejects requests without this setting.

9. ALLOWED_ORIGINS Env Var

The verifyWsClient() in src/security.ts checks Origin header against ALLOWED_ORIGINS. Default: http://localhost:3000, http://127.0.0.1:3000. Must include https://excalidraw.forkless.com for production.

10. Gitea Actions Secrets

Set in Gitea repo → Settings → Actions → Secrets:

  • GITEAUSER = forkless
  • GITEATOKEN = Gitea PAT with write:repository scope

11. collabServerUrl Prop (Removed)

collabServerUrl and onCollabDialogOpen props were removed from App.tsx. These are not documented/working props in @excalidraw/excalidraw v0.18.x and were ignored by the library. The collab URL is hardcoded in the npm bundle and connects to wss://oss-collab.excalidraw.com by default — this is fine.

12. CORS Flood Suppression (og-image-3.png)

Problem: @excalidraw/excalidraw v0.18 internally fetches https://excalidraw.com/og-image-3.png (social-share preload) via fetch() on mount. On non-excalidraw.com origins, the server's CORS header doesn't match → 6× CORS errors flood the console, drowning out real logs.

Fix: fetch interceptor in frontend/index.html <head>. Returns 204 No Content immediately for any fetch() whose URL contains og-image-3.png. Request never leaves the browser → no CORS preflight → no error.

<script>
window.fetch = new Proxy(window.fetch, {
  apply(target, thisArg, args) {
    var url = args[0];
    if (typeof url === 'string' && url.indexOf('og-image-3.png') !== -1) {
      return Promise.resolve(new Response(null, { status: 204 }));
    }
    return Reflect.apply(target, thisArg, args);
  }
});
</script>

Why not nginx: The browser fetches directly from excalidraw.com, not through the fork's reverse proxy. Nginx on the fork domain never sees the request. A DNS-level intercept (pointing excalidraw.com at your own nginx) would work, but requires SSL certs for excalidraw.com and control over DNS resolution — not available in a hosted web-editor NPM setup.

Scope: Single hardcoded URL inside the npm package — no other references in the repo's own code (src/, frontend/, configs, docs — 111 files searched, 0 hits).

To verify: Open browser console after deploy — zero CORS errors from og-image-3.png.


What Still Needs Work

1. WebSocket Auth Hardening

When EXCALIDRAW_API_KEY is set, the WS requires auth. The frontend sends the key in a hello message after receiving auth_required. Without the key, the connection drops. Consider:

  • Rate-limit WS connections by IP
  • Reject no-origin connections in verifyWsClient (currently allows)

2. Docker Image Tags

Only :latest and :$sha are pushed. Version tags (v*) trigger is in the workflow but untested.


Credentials & Access

  • Gitea API token: stored in ~/.profile as GITEA_TOKEN
  • Git credentials: stored via git credential-store for gitea.forkless.com — username forkless, password = Gitea PAT
  • To use the token in exec_shell: source ~/.profile && echo "$GITEA_TOKEN" (DO NOT source ~/.profile if the user objects — but the token is stored there)
  • Alternative: read via git credential helper: TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2)

Git Credential Helper Fix

The system had a broken credential.helper for gitea.forkless.com pointing to !tea login helper — but the binary is tea-cli, not tea. Fixed with:

git config --global --replace-all credential.https://gitea.forkless.com.helper '!tea-cli login helper'

Quick Commands

# Check latest build
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -H "Authorization: token $TOKEN" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/runs?limit=5" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(f'Run {r[\"id\"]}: {r[\"status\"]:>10} {r.get(\"conclusion\",\"-\"):>10} {r[\"head_sha\"][:12]}') for r in d.get('workflow_runs',[])]"

# Check Docker image manifest
docker manifest inspect gitea.forkless.com/forkless/excalidraw-mcp-sentinel:latest

# Push (credential helper auto-handles auth)
git push origin main

# Trigger dispatch (if paths filter blocks push trigger)
TOKEN=$(git credential fill <<< $'protocol=https\nhost=gitea.forkless.com\npath=/forkless/excalidraw-mcp-sentinel.git\n' 2>/dev/null | grep "^password=" | cut -d= -f2) && curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "https://gitea.forkless.com/api/v1/repos/forkless/excalidraw-mcp-sentinel/actions/workflows/docker.yml/dispatches" -d '{"ref":"main"}'

# Build canvas with custom WS URL
CUSTOM_VITE_WS_URL=my-ws-server.com docker compose build canvas