# 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.com` → `wss://excalidraw.forkless.com` - `wss://excalidraw.forkless.com/socket.io/foo` → `wss://excalidraw.forkless.com` - `ws://dev.local:5173` → `ws://dev.local:5173` (preserves ws://) - `https://example.com:3002/path` → `wss://example.com:3002` - `10.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. ```env 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: ```yaml args: - CUSTOM_VITE_WS_URL=${CUSTOM_VITE_WS_URL:-excalidraw.forkless.com} ``` To override at build time: ```bash 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: ```ts 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` ``. 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. ```html ``` **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 local** — `canvasHeaders()` sends `X-Tenant-Id` only when `EXPRESS_SERVER_URL` is unset (local canvas). Remote canvases use default tenant routing. 3. **Skip local canvas server** — `startCanvasServer()` is skipped entirely when `EXPRESS_SERVER_URL` points to a remote host. Prevents port conflicts. 4. **MCP process stability** — `stdin.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 `` 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: ```bash 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: ```bash git config --global --replace-all credential.https://gitea.forkless.com.helper '!tea-cli login helper' ``` --- ## Quick Commands ```bash # 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 ```