Dispatch — remote mobile access to the AGI dashboard
Built under autopilot on 2026-06-30. Goal: reach the locally-running AGI dashboard from a phone over the internet (an exposed port), with basic authentication and rate limiting so only the owner can drive it, plus a genuinely good mobile UI for full remote control (view state, send commands, start sessions, approve decisions).
Contract (definition of done)
Backend — auth
DispatchOptionsconfig (src/types.ts):{ token?, trustLocal?(true), rateLimit? }; token also from envAGI_DISPATCH_TOKEN(env wins). Threaded throughconfig.ts, documented inconfig.example.json.src/server/auth.ts:isLocalRequest(req),extractToken(req)(Authorization bearer /X-AGI-Token/?token=/ cookie),checkAuth(req, opts)→{ ok, local, reason? }. Constant-time token compare.- Fail-safe default: if no token is configured, remote access is REFUSED (local still works). Exposing the port without a token must NOT leak anything.
- trustLocal: loopback (127.0.0.1/::1) bypasses auth by default (zero local
friction; the Stop-hook posts locally).
trustLocal:falseforces the token even locally (needed when a local tunnel like cloudflared makes traffic appear local). - Gate every sensitive HTTP route (
/api/*,/attach,/detach) and the WebSocket upgrade. Static shell (index.html/JS/CSS) stays open (non-sensitive)./hookstays local-only via trustLocal. GET /api/whoami→{ ok, local, dispatchEnabled }so the client can validate a token and decide whether to show the login gate.
Backend — rate limiting
src/server/rateLimit.ts: per-IP sliding-window limiters. General (default 300 req / 60s) and a stricter auth-failure limiter (default 12 fails / 300s → longer cooldown, brute-force guard). Local bypasses. 429 +Retry-After.
Frontend — auth/login + wiring
web/src/lib/auth.svelte.ts: token store (localStorage),whoami(), state (needsAuth,checking,local).web/src/components/Login.svelte: full-screen mobile-first “Dispatch” gate — enter access token → validate → store → connect. Clear error on bad token.api.tsattaches the token (Authorization header) to every fetch; a 401 drops to the login gate.ws.svelte.tsappends?token=to the WS URL.App.sveltegates the app behind auth state. Sign-out control (Settings) clears the token.
Frontend — mobile UI (full remote control on a phone)
- Header collapses cleanly on a phone (no overflow); all primary actions reachable.
- Core flows verified at 390px: fleet → open session → read live/transcript → send a message → approve a decision (attention panel) → start/stop → new session.
- Touch targets ≥44px on the primary controls; no horizontal scroll.
Docs, config, tests
config.example.jsondispatch block + this doc’s “How to expose safely” section.scripts/dispatch-test.ts(deterministic, no live server): local bypass; remote w/o token rejected; remote w/ valid token accepted; invalid rejected; disabled (no token) → remote refused; rate-limit trips + resets. Wired intonpm test.- README/ROADMAP note; memory pointer.
Verification ladder — ALL DONE
- Completion: backend
tscclean;svelte-check0/0;vite buildgreen;npm testgreen (context, learn, discovery, desktop, dispatch — 33 auth/limit assertions). - Quality: 2-round multi-agent adversarial review. Round 1 → 3 real issues
(sibling-prefix path traversal, self-lockout ordering, WS reconnect storm) all fixed.
Round 2 → confirmed those clean + found an unauthenticated
EISDIRcrash-DoS (directory read) → fixed with anisFile()guard + a process-level unhandled-rejection net. Every fix re-verified live (blocked IP recovers with the right token; sibling traversal 404s and leaks nothing;GET /assets404s and the server survives; wrong tokens still converge to 429). - Design: Playwright at 390px — login gate renders, wrong token rejected with an error, correct token loads the full app over the WS, token persists across reload; fleet, session detail + decision approve/deny panel, and the new-session wizard all usable on a phone. Local desktop is unaffected (loopback bypass).
Status: COMPLETE. Dispatch is enabled in the local config.json with a generated token
(trustLocal:true), so it’s usable immediately — see the token in that file (rotate anytime).
How to expose safely (for the README + user)
The dashboard binds to a port; you forward that port to the internet. The token travels in the request — over plain HTTP it can be sniffed. Preferred options:
- Tailscale (recommended): install on the PC and phone; reach the dashboard at the PC’s tailnet IP. No public exposure, encrypted, no port-forward. Keep a token too.
- Cloudflare Tunnel / ngrok: gives an HTTPS URL. Because the tunnel connects to
localhost, set
dispatch.trustLocal:falseso the token is still required. - Raw port-forward: works, but it’s plain HTTP — only acceptable on a trusted network or behind a TLS proxy. Always set a strong token.
Set the token via config.json dispatch.token or the AGI_DISPATCH_TOKEN env var.
Decision log
- D1 — Trust loopback, gate remote (configurable). Loopback bypasses auth so local
use and the Stop-hook have zero friction; remote requires the token.
trustLocal:falsecovers the local-tunnel case where traffic appears to originate from localhost. Rejected: always-require-token (annoying locally) and IP allowlists (brittle on mobile/CGNAT). - D2 — Fail-safe when unconfigured. No token ⇒ remote refused, not open. Exposing the port before setting a token cannot leak state. The safe default beats convenience.
- D3 — Static shell stays public. The HTML/JS bundle is not sensitive; protecting data + commands (API + WS) is what matters. Keeps the login screen itself loadable.
- D4 — Token transport = header + query. Browsers can’t set headers on the WS
upgrade or initial nav, so
?token=is accepted alongsideAuthorization. Stored in localStorage. Constant-time compare to avoid timing leaks. - D5 — Cleartext caveat documented, not solved in-app. TLS termination belongs to a tunnel/proxy; building cert handling is out of scope for “basic.” Boot prints a warning when dispatch is enabled so the risk is visible.