Deployment of OpenChatCut — an open-source, local-first conversational AI video editor (Node 24, React/Vite 8, Remotion rendering, Agent Skills, MCP) — as a self-hosted web service + MCP on telep-mainframe, behind a dedicated Tailscale sidecar node chatcut per the house per-service sidecar convention. All verified working 2026-08-31.
This host has a CPU hardware fault AND this app does video rendering
telep-mainframe panics under sustained all-core load — 2026-08-31-telep-mainframe-mce-hardware-fault. OpenChatCut runs Remotion video renders = exactly that load profile. Mitigations are in place (CPU cap + render concurrency 1, see Safety) but avoid large batch exports. This is the single most important operational caveat on this page.
No app-level auth — tailnet ACL is the ONLY gate
OpenChatCut has no login (it assumes single-user-on-localhost). Exposed on the tailnet it’s reachable by anyone the ACL lets reach the
chatcut(tag:telep) node, and the tailnet is multi-user. If owner-only is desired, tighten thetag:telepgrant in the ACL. The MCP endpoint is separately Bearer-token protected; the web UI is not.
For Agents — quick facts
- Web UI + MCP host:
https://chatcut.taild4189d.ts.net(tailnet-only, no Funnel, LetsEncrypt viatailscale serve)- MCP endpoint:
https://chatcut.taild4189d.ts.net/api/external-mcp/mcp(Streamable HTTP, Bearer auth, protocol2025-06-18,serverInfo.name="openchatcut")- Registered in Claude Code at user scope, status Connected
- Backend loopback:
http://127.0.0.2:5199(Vite dev server, in Docker containeropenchatcut)- App root on host:
/home/levander/openchatcut/— data persists at/home/levander/openchatcut/data
Access
- Web UI + MCP:
https://chatcut.taild4189d.ts.net— tailnet-only, no Funnel, LetsEncrypt cert issued bytailscale serve. - MCP endpoint:
https://chatcut.taild4189d.ts.net/api/external-mcp/mcp(Streamable HTTP, Bearer-token auth). Registered in Claude Code at user scope:
Status: Connected.claude mcp add --transport http --scope user chatcut https://chatcut.taild4189d.ts.net/api/external-mcp/mcp - Token: stored server-side in
/home/levander/openchatcut/.env.local(chmod 600) asOPENCHATCUT_MCP_TOKEN; also saved (redacted) in~/.claude.json. Not reproduced here.
MCP usage nuance
initializeworks headless, but timeline/editing tools require binding atarget_projectand generally a connected browser — open theeditorUrl(the web UI) for those to function.
Architecture
Per the house sidecar convention (same shape as the code-server stack): a dedicated userspace tailscaled fronts a loopback-only backend port.
tailnet client (identified by Tailscale, ACL tag:telep)
│ HTTPS, tailnet-only, LE cert for chatcut.taild4189d.ts.net
▼
tailscaled-chatcut.service --tun=userspace-networking
socket /run/tailscale-chatcut/tailscaled.sock
statedir /var/lib/tailscale-chatcut
node chatcut = 100.85.231.40, tag:telep
│ tailscale serve https://chatcut.taild4189d.ts.net/ → http://127.0.0.2:5199
▼
127.0.0.2:5199 Docker container `openchatcut` (Vite dev server)
Tailscale sidecar
- Unit:
/etc/systemd/system/tailscaled-chatcut.service— userspace-networking, statedir/var/lib/tailscale-chatcut, socket/run/tailscale-chatcut/tailscaled.sock. - Node
chatcut= 100.85.231.40,tag:telep. Provisioned via the ApertureTailnet_provision_nodeone-time auth-key flow, then admin-approved + taggedtag:telepin the console. - Serve mount:
https://chatcut.taild4189d.ts.net/→http://127.0.0.2:5199. Backend bound to 127.0.0.2 (not 127.0.0.1) per the plaintext-port-hardening rule.
Docker
- Image
openchatcut:local, built from/home/levander/openchatcut/Dockerfile. No upstream Dockerfile existed — hand-written. - Container run (host networking, updated 2026-08-31 — see Editor trust-model vs reverse proxy (fixed 2026-08-31) for why
-pbridge was dropped):
Vite binds 127.0.0.2 only (loopback) — verify withdocker run -d --name openchatcut --restart unless-stopped \ --cpus=8 --memory=16g \ --network host \ --env-file /home/levander/openchatcut/.env.local \ -v /home/levander/openchatcut/data:/root/.openchatcut \ openchatcut:local \ npm run dev -- --host 127.0.0.2ss -ltnpthat it shows127.0.0.2:5199and NOT0.0.0.0/LAN/tailnet, preserving the plaintext-port-hardening rule..env.localHOSTis set to127.0.0.2. - Data persists: host
/home/levander/openchatcut/data→ container/root/.openchatcut.
Dockerfile shape
FROM node:24-bookworm → apt git ffmpeg chromium fonts-liberation → ENV to use system chromium for Remotion (PUPPETEER_SKIP_DOWNLOAD=1, REMOTION_CHROME_EXECUTABLE=/usr/bin/chromium, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1, ONNXRUNTIME_NODE_INSTALL=skip) → git clone → npm install → cp .env.example .env.local → npm run predev → the Vite host patch (below) → COPY patch-trust.mjs + RUN node /tmp/patch-trust.mjs (the trust-model patch, see Editor trust-model vs reverse proxy (fixed 2026-08-31)) → CMD npm run dev -- --host 0.0.0.0.
The CMD host is overridden at runtime by the run command’s npm run dev -- --host 127.0.0.2 (loopback-only bind, required for the trust-model fix).
Runs off npm run dev (a Vite dev server) — upstream provides no production/headless server command, so this is the supported path.
Gotchas discovered
1. Vite
allowedHostsblocks the tailscale-serve hostname (403)The Vite-8 equivalent of the FastMCP 421 Host-header trap seen on 2026-08-31-telep-kb-mcp-server / drive-mcp. Behind
tailscale serve, requests arrive withHost: chatcut.taild4189d.ts.net, and Vite’s dev server returns 403 “Blocked request. This host is not allowed” for BOTH the UI and the MCP endpoint. Vite 8 has no--allowed-hostsCLI flag (only--host). Fix baked into the Dockerfile — note the server block lives inconfig/vite.config.ts, not a top-levelvite.config:RUN sed -i "s|port: 5199,|port: 5199,\n allowedHosts: ['chatcut.taild4189d.ts.net'],|" config/vite.config.ts
-
Vite binds localhost by default and does NOT read the CRA-style
HOSTenv — must pass--host 0.0.0.0through to Vite vianpm run dev -- --host 0.0.0.0. The repo’sscripts/dev-profile.mjsforwards argv to Vite. -
onnxruntime-node postinstall tries to download CUDA/GPU execution-provider libs on linux/x64 (fails + hits an unroutable Akamai edge via MagicDNS). Set
ONNXRUNTIME_NODE_INSTALL=skip— keeps the bundled CPU binary. -
Docker buildkit couldn’t resolve DNS (didn’t inherit host MagicDNS
100.100.100.100) — build with--network=host. -
Runs off
npm run dev(a Vite dev server); upstream provides no production/headless server command — that is the supported path.
Safety
- MCE hardware fault mitigations: Docker
--cpus=8hard cap, plus app envOPENCHATCUT_RENDER_CONCURRENCY=1andOPENCHATCUT_MAX_ACTIVE_EXPORTS=1in.env.local. Still avoid large batch exports — see the danger callout at top and 2026-08-31-telep-mainframe-mce-hardware-fault. - No app-level auth: access is governed entirely by the tailnet ACL for
tag:telepon a multi-user tailnet. Tighten that grant if owner-only is required. MCP endpoint is separately Bearer-protected.
Editor trust-model vs reverse proxy (fixed 2026-08-31)
After the initial deploy the MCP endpoint worked, but the web editor UI showed “Could not load the MCP connection token. Retry from a trusted editor window.” and editor writes failed with 403 {"error":"invalid request origin"}. The MCP Bearer-token path was exempt and fine — this only hit the browser editor.
Root cause — OpenChatCut's hardcoded "local-device trust model"
Implemented in
server/project-store-http-auth.ts+server/plugins/request-shape-gate.ts. Every state-changing request must satisfy ALL of:
- loopback socket —
isLoopbackAddress(req.socket.remoteAddress)- loopback Host header —
loopbackHost()requires Host ∈{localhost, 127.0.0.1, ::1}(a DNS-rebinding defense)- same-origin —
Origin.host === Host- Sec-Fetch-Site — same-origin or none
Only
/api/external-mcp/mcp(Bearer route) and/upload?handoffare exempt. This assumes the browser talks tohttp://localhost:5199and is fundamentally incompatible with serving via a real hostname behindtailscale serve.
Two distinct failures and their fixes:
(2) loopback-socket check — fixed by dropping the Docker bridge
Failed first because the container ran on a Docker bridge with -p 127.0.0.2:5199:5199; the app saw the request source as the Docker gateway (172.17.0.1), not loopback.
Fix: run the container on --network host, binding Vite to --host 127.0.0.2 (loopback only — verified via ss -ltnp that it shows 127.0.0.2:5199 and NOT 0.0.0.0/LAN/tailnet, preserving the plaintext-port-hardening rule). Then tailscale serve → 127.0.0.2:5199 and the app sees a real loopback source. See the updated run command in Docker.
(2b) loopback-Host check — fixed by an env-derived app patch
Still failed after (2) because behind serve the Host is chatcut.taild4189d.ts.net. No proxy trick fixes this: Origin is browser-set from where the page loaded, so rewriting Host would break the same-origin check.
Fix: patched loopbackHost() to ALSO accept the host of OPENCHATCUT_EDITOR_URL (env-derived, not hardcoded). Patch script /home/levander/openchatcut/patch-trust.mjs, baked into the Dockerfile (COPY patch-trust.mjs + RUN node /tmp/patch-trust.mjs).
Verified: POST https://chatcut.taild4189d.ts.net/api/external-agent/bootstrap (with Origin + Sec-Fetch-Site: same-origin + x-openchatcut-editor-bootstrap: 1) now returns 200 with the mcpToken.
Security note (deliberate, bounded relaxation)
Patching the DNS-rebinding host check is safe in THIS deployment because:
- the node is reachable only over the ACL-gated tailnet (loopback-socket ingress =
tailscale serveonly);- the same-origin + Sec-Fetch-Site CSRF defenses remain fully intact.
A random web page cannot make
chatcut.taild4189d.ts.netresolve for a victim (MagicDNS-controlled) nor forge the same-originOrigin. Do NOT copy this relaxation to any Funnel-exposed or public service.
Reusable lesson — local-first apps behind
tailscale servefail in THREE Host/origin layersAll three are Host/origin-related and should be expected for any future local-first service:
- (a) Vite
allowedHosts403 (see Gotchas discovered #1)- (b) loopback-socket check needs
--network host, not a bridge- (c) loopback-Host-header trust check needs an app patch
Same family as the FastMCP 421 Host-header trap (project_telep_kb_mcp / telep-kb, drive-mcp).
Related
- tailnet-service-exposure-convention — the per-service dedicated-node pattern this follows
- 2026-08-31-tailnet-plaintext-port-hardening — why the backend binds
127.0.0.2 - 2026-08-31-nextcloud-drive-code-server-runbook — sibling sidecar stack (Drive + code-server + drive-mcp), same shape
- 2026-08-31-telep-kb-mcp-server — the FastMCP 421 Host-header trap that gotcha #1 mirrors
- 2026-08-31-telep-mainframe-mce-hardware-fault — the CPU fault that constrains render concurrency
- telep-mainframe-handover — host operator handover
- telep-mainframe — the host