fix(web): the global Inbox in the browser

Merged
DA
6d ago
+278-13

What was asked

On tryluke.dev/app the global Inbox showed no items, though per-task inboxes did, and the same account's desktop showed them all.

Root cause

The account inbox is followed over Neon Realtime: the host fetches sealed statements from /api/live/inbox and opens a WebSocket to the Realtime socket that answer names (packages/host/src/account-inbox-follow.ts). The web app's Content-Security-Policy (root vercel.json, on the /app routes) had connect-src 'self' plus PostHog and Sentry, and no Realtime host. So the browser refused the socket. Realtime's client then reconnects forever without reporting anything, no answer was ever published, and app state's inbox slice stayed empty.

These all read that slice, so they were all empty on the web:

  • the global Inbox
  • the Inbox badge
  • the inbox, chat and failed-run toasts and notifications

The agent-ended toast doesn't depend on it. Per-task inboxes read their items directly by act, which is why they worked. The desktop's host runs in Electron's main process, where no page policy applies.

Things I checked and ruled out:

  • /api/live/inbox already accepts the cookie through resolveHostedUserId.
  • linkClientNotices and the follow are wired on the web exactly as on the desktop.
  • Production's Realtime branch accepts any Origin (allowed_origins is empty; I checked with a WebSocket handshake).

Fix

  • CSP: connect-src on both /app routes now allows wss://*.us-east-1.aws.neon.tech.
    • CSP only allows a wildcard as the leftmost label, so a form like *-neonrealtimesocket.… isn't valid.
    • Each branch gets its own socket, <branch>-neonrealtimesocket.compute.c-10.us-east-1.aws.neon.tech. Production and every Preview branch I checked are in the same cell.
    • I chose the project's region over its cell (c-10): a Neon project never changes region, but nothing promises it keeps its cell.
    • The headers are static in vercel.json, so the build can't write in the exact host from NEON_REALTIME_URL.
    • A wss source also matches an https address, so this works whichever scheme the URL is written with.
  • Loud failure: a follow that has published nothing 30 seconds after it began is now reported once, as a Warn:
    • It goes to the host's line (Effect.logWarning, which the Reporter receives) and to the runtime's error reporters (ErrorReporter.report, which reaches Sentry on the desktop).
    • It says why the follow last stopped, and never includes an address or a seal. The follow keeps retrying.
  • Sentry on the web: the web runtime installs no Effect error reporter yet. So the tab's Sentry SDK now sends every kind of request the page policy refuses (securitypolicyviolation) once, as a warning, naming only the directive and the origin. The next connection the policy forgets will show up in Sentry.

Tests

  • apps/web/tests/vercel-services.test.ts: every /app route's connect-src admits production's and a Preview's Realtime socket, and no other socket. This fails without the fix.
  • packages/host/src/compose-coding-agents.test.ts:
    • a follow whose socket never delivers is reported once at 30 s, as a warning with no address or seal, and keeps following. This fails without the fix.
    • a follow that answers in time reports nothing.
  • apps/web/tests/web-sentry.test.ts: a refusal is sent once per directive and origin, without its path or query.

Checks

  • pnpm check:affected ran with the sandbox under memory pressure:
    • knip found an unused export, now fixed.
    • The panel and web typechecks were OOM-killed. I re-ran the web typecheck alone and it is clean; I didn't touch the panel.
    • Some compose-live and open-on-mac tests timed out under load. Run alone, all of them pass.
  • lint and oxlint are clean.
  • I couldn't test this in a signed-in browser here. CI and a Preview are what confirm it.

Made with Luke


Devin Review