Files

11 KiB
Raw Permalink 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.

13. MCP Server Fixes

Code changes in src/index.ts:

  1. Auto-title junk removed (create_element handler) — no longer auto-injects "Title"/"Text here" on containers when text is provided. Card layout still works via explicit title+subtitle params.

  2. X-Tenant-Id header scoped to localcanvasHeaders() sends X-Tenant-Id only when EXPRESS_SERVER_URL is unset (local canvas). Remote canvases use default tenant routing.

  3. Skip local canvas serverstartCanvasServer() is skipped entirely when EXPRESS_SERVER_URL points to a remote host. Prevents port conflicts.

  4. MCP process stabilitystdin.on('close') handler uses setTimeout(shutdown, 1000) instead of immediate process.exit(0). Prevents crash on transient disconnects during CodeWhale init.

MCP config (~/.codewhale/mcp.json):

  • Server registered as excalidraw with stdio transport
  • Points to https://excalidraw.forkless.com (production)
  • Uses /tmp/excalidraw-mcp.db for local DB (separate from production)

Known issue: Auto-title junk still appears when production canvas runs old Docker image. The fix is in main but the container must be redeployed. Until then, delete stray _title/_subtitle elements via API.

14. Favicon Suppressor

frontend/index.html — added <link rel="icon" href="data:,"> to prevent 404 on /favicon.ico. Combined with the fetch interceptor (section 12), the console is now clean of network errors.


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.

3. Production Container Needs Redeploy

The auto-title fix (section 13) is committed and the Docker image is built, but the running container at excalidraw.forkless.com still runs the old image. Until redeployed, elements created via REST API batch will have stray _title/_subtitle junk elements. Delete them via:

curl -s https://excalidraw.forkless.com/api/elements | python3 -c "import sys,json;d=json.load(sys.stdin);[print(e['id']) for e in d['elements'] if e.get('text') in ('Title','Text here')]" | xargs -I{} curl -s -X DELETE "https://excalidraw.forkless.com/api/elements/{}"

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