11 KiB
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:latestgitea.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— readsprocess.env.CUSTOM_VITE_WS_URL, defaults towss://excalidraw.forkless.com, exposes asimport.meta.env.CUSTOM_VITE_WS_URLfrontend/src/App.tsx— usesimport.meta.env.CUSTOM_VITE_WS_URL
URL normalization (auto handles user input):
excalidraw.forkless.com→wss://excalidraw.forkless.comwss://excalidraw.forkless.com/socket.io/foo→wss://excalidraw.forkless.comws://dev.local:5173→ws://dev.local:5173(preserves ws://)https://example.com:3002/path→wss://example.com:300210.0.101.100→wss://10.0.101.100[::1]:3000→wss://[::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=forklessGITEATOKEN= Gitea PAT withwrite:repositoryscope
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:
-
Auto-title junk removed (
create_elementhandler) — no longer auto-injects "Title"/"Text here" on containers whentextis provided. Card layout still works via explicittitle+subtitleparams. -
X-Tenant-Id header scoped to local —
canvasHeaders()sendsX-Tenant-Idonly whenEXPRESS_SERVER_URLis unset (local canvas). Remote canvases use default tenant routing. -
Skip local canvas server —
startCanvasServer()is skipped entirely whenEXPRESS_SERVER_URLpoints to a remote host. Prevents port conflicts. -
MCP process stability —
stdin.on('close')handler usessetTimeout(shutdown, 1000)instead of immediateprocess.exit(0). Prevents crash on transient disconnects during CodeWhale init.
MCP config (~/.codewhale/mcp.json):
- Server registered as
excalidrawwith stdio transport - Points to
https://excalidraw.forkless.com(production) - Uses
/tmp/excalidraw-mcp.dbfor 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
~/.profileasGITEA_TOKEN - Git credentials: stored via
git credential-storeforgitea.forkless.com— usernameforkless, password = Gitea PAT - To use the token in exec_shell:
source ~/.profile && echo "$GITEA_TOKEN"(DO NOT source~/.profileif 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