Skip to content

e2e/dev-built app silently hijacks the installed app's HTTP API port on macOS (127.0.0.1:7654 steals from 0.0.0.0:7654) #21

Description

@andyshinn

Summary

On macOS, running the Playwright e2e suite while an installed CoreSense app is open can silently steal the installed app's HTTP API port on loopback. The installed app's renderer keeps sending requests to http://127.0.0.1:7654, those requests are now answered by the e2e app — which has a different API key from its throwaway profile — and every mutating call fails with 401 {"error":"Invalid API key"}.

Observed symptom: with the stable app open during an e2e run, Duplicate / Add macro failed with an API error (Couldn't duplicate macro: Invalid API key). It healed itself once the suite finished.

The two loopback/wildcard bind facts below were verified empirically on macOS (Darwin 24.6.0, Node v26.5.0), not inferred.

Root cause

isDev conflates "the renderer is served by Vite" with "this is a dev instance", and the port picker uses the wrong one.

  1. src/main/index.ts:78const isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL. This is a build-time Vite define that constant-folds; it is not a runtime check.
  2. src/main/storage/paths.ts:13 (if (!app.isPackaged)) and src/main/storage/settings.ts:104 (if (isPackaged())) answer the same "am I a dev instance?" question using runtime identity.
  3. These agree for pnpm start and for an installed build. They disagree for exactly one case: a prod-built main bundle run unpackaged — which is precisely what e2e does.
  4. @electron-forge/plugin-vite's vite.base.config.js sets MAIN_WINDOW_VITE_DEV_SERVER_URL: command === 'serve' ? JSON.stringify(url) : undefined, and package builds with command === 'build'. Confirmed by running the real prod build of src/main/index.ts into a scratch outDir: the emitted bundle contains var rj=!1 (isDev = false) passed straight into startServer(..., {dev: rj, bindAddress: o}).
  5. So the e2e app takes the production port: src/main/server.ts:73 const defaultPort = opts.dev ? DEFAULT_PORT_DEV : DEFAULT_PORT_PROD with DEFAULT_PORT_PROD = 7654 (server.ts:54).
  6. The e2e temp profile binds loopback: src/main/index.ts:117-118 bindAll = proxy.enabled && proxy.bindAll'127.0.0.1', because DEFAULT_APP_SETTINGS.proxy.bindAll is false (src/shared/types.ts:521-526).
  7. The installed app with the LAN proxy on is bound to 0.0.0.0:7654.
  8. On Darwin, a more-specific bind coexists with an existing wildcard bind, and wins loopback. No EADDRINUSE is raised, so the only collision defence — src/main/server.ts:370 if (err.code === 'EADDRINUSE' && attempt < MAX_PORT_PROBES) — never fires. listenWithFallback never probes 7655, and the e2e app logs server listening on http://127.0.0.1:7654 as though it legitimately owns the port.
  9. The installed renderer targets exactly the stolen address: src/renderer/App.tsx:125-127 builds http://127.0.0.1:${window.coresense.httpPort} (hardcoded 127.0.0.1 regardless of what the server actually bound), and the fallback at App.tsx:24 is the literal http://127.0.0.1:7654.
  10. Different profile ⇒ different key. tests/e2e/support/launch.ts:56 sets CORESENSE_USER_DATA to a fresh mkdtemp dir; src/main/storage/paths.ts:13-19 honours it because app.isPackaged === false; src/main/api/middleware/auth.ts:14 generates a new key into that temp config.json. A length-equal mismatch hits auth.ts:63c.json({ error: 'Invalid API key' }, 401) — the only place in the codebase that emits that string.
  11. src/renderer/lib/api.ts rethrows the server message; MacroLibrary.tsx:51-61Couldn't duplicate macro: Invalid API key, MacroStudio.tsx:70-75Couldn't save macro: Invalid API key.

Preconditions (all three required)

  1. The installed app is bind-allproxy.enabled && proxy.bindAll0.0.0.0:7654. This is necessary, not incidental. Without it the installed app is on 127.0.0.1:7654, the e2e app gets a genuine EADDRINUSE, listenWithFallback relocates it to 7655, and there are zero symptoms. That's why this hasn't bitten on every prior e2e run — it only appears once a user turns on the LAN proxy.
  2. .vite/build/index.js is a pnpm package output when e2e launches. pnpm test:e2e is just playwright test; tests/e2e/support/launch.ts:9 points at a path and never builds. Both pnpm start and pnpm package write that same outDir, so the port is decided by whichever ran last. With a start build in place, e2e targets 7754 and hijacks the dev instance instead. CI always hits the package case (.github/workflows/ci.yml runs pnpm package then pnpm test:e2e).
  3. macOS/BSD. On Linux the same pair raises EADDRINUSE, so this will not reproduce in Linux CI.

Empirical evidence

Reproduced independently on scratch ports; 7654/7754 were only ever read via lsof, never bound.

  • Overlap succeeds. bind 0.0.0.0:48101 → OK; bind 127.0.0.1:48101 → OK, no EADDRINUSE. Cross-process, per lsof: TCP *:48110 (LISTEN) and TCP 127.0.0.1:48110 (LISTEN) coexisting under different PIDs. Control: 127.0.0.1 twice → listen EADDRINUSE.
  • Routing is by specificity, not bind order. GET 127.0.0.1:48101 → SPECIFIC; GET localhost:48101 → SPECIFIC; GET <LAN IP>:48101 → WILDCARD. Reversing the bind order gives the same result.
  • hono behaves identically. Through the real serve({fetch, port, hostname}) call from server.ts:365 with @hono/node-server@2.0.11: both binds succeed, loopback → specific, LAN → wildcard. Its error event does fire EADDRINUSE when it should.
  • SO_REUSEADDR is load-bearing, and only on the second socket. Python (which doesn't set it by default): wildcard without the flag + specific with it → OK; wildcard with + specific without → errno 48. libuv sets it unconditionally on every TCP listen socket, so an Electron process is always the permissive second binder — the installed app's socket options cannot prevent this.
  • End-to-end simulation using the verbatim listenWithFallback body from server.ts:356-380 across two processes: fresh loopback conn → STABLE; e2e process starts, prints E2E bound port = 48160 (took it, no fallback probe); lsof shows both sockets; fresh loopback conn → E2E; fresh LAN conn → STABLE; after e2e exits, fresh loopback conn → STABLE again.
  • Local forensics match the incident. Installed app PID 88635 on *:7654 with proxy {enabled:true, bindAll:true}. macros.json has writes at 17:54 / 17:56 / 18:00 and nothing after. A worktree out/ mtime of 17:49 (a pnpm package) and tests/e2e/ mtime of 18:07 (a Playwright run) put an e2e run against a package bundle minutes after the last successful macro write. The real profile's config.json is still dated May 7, so the installed app's own key was never rotated.

Why it looked macro-specific, intermittent, and self-healing

  • Only loopback is stolen. The e2e app binds 127.0.0.1, so LAN clients and the phone/bridge path keep reaching the real app throughout. (This is also the clean discriminator against a "rotated API key" theory — that would 401 the LAN path too.)
  • Reads kept working not because of pre-hydration but because the macros list is never re-fetched over HTTP — it comes from the mount snapshot plus WebSocket emit.macros pushes, and the WebSocket (ws://127.0.0.1:7654/ws, established before the e2e app started listening) is an already-open TCP connection a new listener cannot steal. src/renderer/hooks/useWebSocket.ts has no heartbeat, so it never notices.
  • Intermittency: HTTP keep-alive (hono inherits Node's ~5s keepAliveTimeout) lets fetches issued inside the idle window still reach the original app, so the same click can succeed or 401 depending on timing.
  • Flickering, not a solid takeover: playwright.config.ts sets workers: 1 and the harness creates/closes a fresh Electron app per test, so loopback reverts to the installed app between tests — and fully self-heals when the suite exits. A 401 persisting after the suite finished would be a different bug.
  • Nothing else was visible because sitting in the Macros panel issues no other HTTP: the library reads from the zustand store, validation/preview are client-side, and the tool:macros rail fetches nothing. Other background calls fail silently by design (putUiState's catch is a no-op, useChannelActivity keeps the prior chart, snapshot/capabilities are mount-only).

Ruled out: template-validation 400 (client gates Save on the same shared validator and would read "invalid template"); disk/quota/write contention (src/main/macros/store.ts writes fire-and-forget after returning, so the route still 200s); e2e corrupting the real profile (paths.ts redirect verified, real config.json untouched); macro limits or name uniqueness (no such checks exist); client === null (returns early with no toast); the user being in the dev app (dev is on 7754 with the CoreSenseDev profile).

Note that listenWithFallback is not itself broken — its EADDRINUSE handler fires and falls back correctly when both binders use the same address. It is simply blind to a wildcard-vs-loopback conflict, because the kernel reports no conflict.

Recommended fix

Primary — split the overloaded isDev (src/main/index.ts only)

// Is the renderer served by Vite? Only under `electron-forge start`.
const viteDevServerUrl = MAIN_WINDOW_VITE_DEV_SERVER_URL;
// Is this an installed app? Decides the port/mDNS namespace, and must match the
// predicate that already redirects userData (storage/paths.ts:13) and seeds the
// dev proxy port (storage/settings.ts:104).
const isDevInstance = !app.isPackaged;
line current becomes
index.ts:120 startBridge({ dev: isDev, … }) dev: isDevInstance
index.ts:127 const rendererDir = isDev ? null : … viteDevServerUrl ? null : …
index.ts:129 startServer(…, { dev: isDev, … }) dev: isDevInstance
index.ts:147 buildMdnsServices({ dev: isDev, … }) dev: isDevInstance
index.ts:204 const csp = isDev ? relaxed : strict viteDevServerUrl ? relaxed : strict

The CSP line matters: keying it on viteDevServerUrl preserves today's behaviour exactly (e2e already gets the strict prod CSP); keying it on isDevInstance would wrongly relax the CSP for e2e. Use !app.isPackaged directly rather than isPackaged() from runtime/appInfo at module scope, to avoid depending on setAppInfo() ordering. src/main/server.ts is untouched.

This also fixes hand-run built bundles (electron .vite/build/index.js) and the /run skill path, which a harness-only fix would not. Tradeoff: e2e then lands on 7754/7755 and can collide with a concurrent pnpm start — but that is a same-address collision, so it raises a real EADDRINUSE and the fallback handles it.

Hardening 1 — CORESENSE_HTTP_PORT, set to 0 by the harness

In src/main/server.ts, extract a pure, unit-testable resolver (env override wins over the dev/prod default; 0 = ephemeral) to replace line 73, and give listenWithFallback an allowFallback flag so an explicitly requested port is a contract rather than a hint. Verified safe with @hono/node-server@2.0.11: it does server.listen(options?.port ?? 3000, …), so 0 survives the ?? and info.port carries the real ephemeral port. boundPort is assigned before the promise resolves and createRoutes({ port: () => boundPort }) is lazy, so /api/capabilities, coresense:get-http-port, appUrl, and the will-navigate allowlist all pick it up. No e2e spec hard-codes a port.

In tests/e2e/support/launch.ts:54, add CORESENSE_HTTP_PORT: '0' alongside the existing overrides, plus a tripwire after firstWindow() that throws if the resolved window.coresense.httpPort is 7654 or 7754. Also assert app.isPackaged === false and that app.getPath('userData') equals the temp dir — that turns the packaged-launch data-loss trap (below) into a failing test instead of a silent overwrite.

Hardening 2 — renderer fallback (src/renderer/App.tsx:137-146)

Gate the FALLBACK_BASE_URL retry on !injectedPort. A first-party window handed its own server's port by the preload should surface an error, not silently attach its snapshot, WS stream, and every mutating call to whatever CoreSense answers on 127.0.0.1:7654. This hazard is bidirectional and survives any main-process port fix.

Hardening 3 — port namespace and bridge

  • Make the HTTP fallback walk disjoint from the bridge ports. server.ts:56,370-375 walks port+1 up to 50 times from 7654/7754, and src/shared/types.ts:39-40 put BRIDGE_DEFAULT_TCP_PORT = 7655 / _DEV = 7755 as literally the next port — so one genuine collision relocates HTTP onto the bridge's default port, producing a second, more confusing failure. Skip 7655/7755, or step by +10.
  • Give src/main/bridge/tcp.ts:83-97 the same probe loop, or at minimum a distinct persistent "port in use" state in BridgeStatus. Today server.listen(port, bindAddress) rejects on first error and src/main/bridge/index.ts:38-39 swallows it into a transient toast; the bridge then reports tcpPort: null and the Proxy panel tells the user to relaunch — actively wrong advice, since relaunching re-collides.

Explicitly rejected — exclusive: true

Does not work on macOS: bind 0.0.0.0:37654 then bind 127.0.0.1:37654 with {exclusive: true} → bound OK, both orders. Node's exclusive only disables cluster handle sharing; libuv's uv__tcp_bind sets SO_REUSEADDR unconditionally. serve() also provides no path to pass listen options through. Costs a rewrite of the listen path, buys nothing, creates false confidence.

Optional — probe-before-bind

Only if you want cover for cases the primary fix structurally cannot see (two installed copies, or an unrelated process on 7654): before serve(...), and only when the port was not explicitly pinned, fetch http://127.0.0.1:${port}/api/capabilities with a ~250 ms timeout (it's in PUBLIC_PATHS, auth.ts:51, so no key needed) and skip the port if a CoreSense-shaped response comes back. Probe 127.0.0.1 regardless of bindAddress — that's the address that gets stolen. It's a heuristic (concurrent starts still race) and duck-types the response; adding a literal app: 'coresense' marker to Capabilities would make it unambiguous.

Related hazards found while investigating (secondary)

  • No single-instance lock exists. grep -rn "requestSingleInstanceLock|second-instance" src/ returns nothing; the only startup quit guard is electron-squirrel-startup (Windows-installer-only). playwright.config.ts:3-4 claims "Electron apps are single-instance" — that comment is wrong and should be corrected. A naive lock would break the suite (every launchApp() would instantly quit and yank focus to the real window), so any lock must be keyed on the userData dir or skipped when CORESENSE_USER_DATA / CORESENSE_FAKE_TRANSPORT is set.
  • A sneakier sibling with no error at all: running the locally packaged build (out/CoreSense-darwin-arm64) alongside the installed app hijacks 7654 with the same API key (packaged → real userData), so there's no 401 — instead two processes share one messages.db and settings dir, and the renderer talks to whichever bound loopback last. Same trap if tests/e2e/support/launch.ts ever points executablePath at a packaged bundle: paths.ts:13 gates the CORESENSE_USER_DATA redirect on !app.isPackaged, so the harness would silently read and overwrite the real profile, and close() only rmSyncs the temp dir.
  • src/main/window/state.ts:22 is the one writable-state path calling app.getPath('userData') directly instead of the runtime/userData resolver — two sources of truth that agree only by convention.
  • tests/e2e/support/launch.ts:42-47 copies the developer's entire shell env, stripping only ELECTRON_RUN_AS_NODE. BRIDGE_MDNS_NAME (read at index.ts:152) is still live and would leak in.
  • Zero test coverage for any bind-collision path. grep -rn "EADDRINUSE" tests/ returns nothing; listenWithFallback and bridge/tcp.ts's listen are both untested.

Environment

  • macOS 15.7.7 (Darwin 24.6.0), arm64
  • Installed CoreSense 0.0.13-dev.0, proxy: { enabled: true, bindAll: true, port: 5800, mdns: true }
  • Node v26.5.0, @hono/node-server@2.0.11, Electron 43

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions