fix: read session cookies from the browser target, not an arbitrary page#28
Conversation
session-refresh.ts exists because the extension's own targets never answer CDP.
It reads cookies to sidestep that, on the premise that "page targets respond to
CDP reliably" — but that premise does not hold, and the fallback bet on it:
const target = pages.find(t => t.url.includes("superhuman.com")) ?? pages[0];
Measured against a live browser with six page targets, Network.getCookies hung
indefinitely on one while the other five answered in milliseconds. Which target
hangs drifts: a tab that hung on one probe answered three hours later, and a
different tab had started hanging by then. It is a busy renderer, not a
property of any site — so no page target can be assumed responsive, and
pages[0] is an arbitrary tab.
With no Superhuman tab open, that fallback attaches to whatever is first. If
that renderer is wedged the read never returns — and a hang is not a rejection,
so the try/catch cannot turn it into the documented null. The caller just
stops. On-demand refresh then fails exactly as it did before this module
existed, which is the attachment-download 401 it was written to prevent.
Read from the browser-level target via Storage.getCookies instead: no renderer
to wedge, and no Superhuman tab (or any tab) need be open. Bound the read too,
so a future stall degrades to null rather than hanging.
Two details worth flagging for review:
- Attach via CDP.Version()'s webSocketDebuggerUrl. `target: "browser"` does not
work — chrome-remote-interface treats a target string as an id and looks it
up in CDP.List(), which never contains the browser. Using CDP.Version()
rather than fetch() also keeps discovery on the library's transport, so the
existing "no network call without a browser" test still holds; an earlier
fetch()-based draft broke it, correctly.
- Storage.getCookies takes no `urls` filter and returns the whole store, so
scope with RFC 6265 host-matching against the two hosts the backend calls
authenticate against. A plain *.superhuman.com filter is wrong: it also picks
up media.superhuman.com's own device-id and sends duplicate names with
different values.
Verified live: the header is the same 5 name=value pairs as the old page-target
path (set-identical; order differs, which RFC 6265 does not constrain and the
backend accepts), read in 26ms, and isSessionRefreshHealthy() — a real CSRF
exchange against accounts.superhuman.com — returns true. A dead port still
returns null.
Tests 432 pass / 6 fail, matching main exactly (the 6 are pre-existing live
E2E --attach failures). tsc --noEmit clean. The new page-target assertion is
mutation-verified: reverting the discovery to CDP.List page enumeration fails
it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
The first cut of this PR replaced the page-target read with a browser-level one outright. That was verified only against Chromium on Linux, by passing port 9222 explicitly. The desktop deployment is Electron on 9252 (getCDPPort's default), and nothing in this repo had ever attached browser-level before — CDP.Version and Storage.getCookies both had zero uses. So the change swapped a read that demonstrably works in Electron for one that is unverified there, on what is the primary deployment. Fixing Linux by regressing macOS is not a trade worth making on an untested assumption. Keep the browser target as the preferred route — it has no renderer, so it cannot wedge, and needs no Superhuman tab open — but fall back to page targets when it is unavailable for any reason (no webSocketDebuggerUrl, no Storage domain, error, or timeout). Worst case on Electron is now the previous behaviour rather than a break. The fallback is also better than what it replaces: it orders Superhuman tabs first and then ADVANCES to the next candidate when one does not answer within the timeout, instead of betting everything on `?? pages[0]`. A wedged renderer costs a timeout, not the refresh. Verified live on Linux: browser-level returns 5 cookies in 28ms; forcing CDP.Version to throw (simulating an Electron that does not serve Storage on its browser endpoint) falls through to the page path and returns 5 cookies in 25ms — the same set, confirmed by comparing sorted name=value pairs. Both agree. isSessionRefreshHealthy (a real CSRF exchange against accounts.superhuman.com) returns true, and a dead port still returns null. The page-target test now asserts ORDER (browser attempted first) rather than "never touches pages", since pages are a legitimate fallback now. Tests 433 pass / 6 fail — the +1 over main is this test; the 6 are pre-existing live E2E --attach failures. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
Two independent adversarial reviews (a subagent and codex) converged on the
same findings. Addressed here, plus one thing both missed that fell out of
testing their claims.
Fixed:
- Bound the CDP attach. withTimeout only wrapped commands; `await CDP(...)` sat
outside it, so a target advertising a websocket endpoint that never completes
its handshake would hang before any bounded call ran — defeating the claim
that both routes are bounded. (codex)
- One global deadline for the whole read, replacing per-call timeouts. The page
sweep was pages x 2 x timeout with no ceiling: Network.enable and getCookies
were each timed separately, so six tabs could cost 120s, and a browser with
dozens of tabs could stall the CLI for minutes. Measured: one wedged tab
already burns the full 10s. (both)
- Honour cookie PATH. Storage.getCookies takes no `urls` filter and returns the
whole store, so the domain-only filter admitted cookies the old
Network.getCookies({urls}) excluded. `csrf=current; Path=/` next to
`csrf=old; Path=/legacy` would have sent both and let the backend choose.
Now implements RFC 6265 §5.1.4 path-match against the real backend path. (both)
- Validate CDP_TIMEOUT_MS. parseInt("garbage") is NaN and setTimeout(fn, NaN)
fires immediately, so a config typo made every call "time out" and refresh
return null — a silent stale token. Non-finite/non-positive now fall back to
the default. (both)
- Real unit tests for the pure logic, which had none: domain-matching
(subdomains, host-only, label-vs-substring, case), path-matching, and
toCookieHeader (media.* exclusion, path-scoped duplicates). 13 new tests.
- Drop the Storage guard. chrome-remote-interface builds command stubs from
/json/protocol fetched from the browser, not per-target, so `client.Storage?.
getCookies` is always a function and the guard could never detect a browser
that does not serve Storage. Such a browser rejects at call time and hits the
catch. The comment claimed otherwise. (subagent)
Known limitation, documented rather than half-fixed:
Both reviews flagged that the fallback triggers on EMPTY, not on WRONG — a
populated-but-stale default-context jar is returned and the page path never
runs. Real: Storage.getCookies reads the default browser context, and a page can
live in another (incognito, or an Electron partition).
Gating on real authentication was implemented and then reverted, for two
reasons. First, sessions.getCsrfToken is not an auth check: measured against the
live backend it returns 200 and a csrfToken with bogus cookies AND with no
Cookie header at all — so the pre-existing isSessionRefreshHealthy docstring
("a real proof the session is live") is wrong, and its claim is corrected here.
Second, the only real proof is sessions.getTokens, and looping that over every
candidate turns a fast "no session" failure into a full page sweep — which
broke three read-hang regression tests (5s timeouts), the suite guarding exactly
this class of bug. Trading a real regression for a theoretical fix is not worth
it.
The right fix is to enumerate browserContextId from CDP.List and read each
context via Storage.getCookies({ browserContextId }) — no page attach, no sweep,
no cost when only the default context exists. Left as a follow-up.
Verified live: browser-level 5 cookies in 29ms; forced page-fallback 5 cookies
in 11ms; both routes agree; dead port returns null; CDP_TIMEOUT_MS=garbage still
works instead of silently disabling refresh. Tests 445 pass / 6 fail (the 6 are
pre-existing live E2E --attach; +13 over main is the new tests). Hang
regressions green. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
Round-2 adversarial review found that round 1 traded one bug for another, and
that a chunk of what it added is dead.
1. The page sweep could not advance past a HUNG target (HIGH).
Each per-target call was handed deadline.remaining() — the whole budget. So the
first tab that hangs (rather than errors) consumed all 10s and the deadline then
skipped every remaining candidate. Round 1 fixed N x timeout by replacing it
with one-target-eats-everything: the sweep could only advance on a fast failure,
never on a hang, which is the single failure mode it exists to survive.
Reviewer measured, live: a wedged tab burned 10002ms and the sweep yielded
nothing, while three responsive tabs sat further down the list answering in
~20ms. That is a silent stale token — the exact failure this module prevents.
Cap each target at min(remaining, PER_TARGET_CAP_MS=2s). Responsive targets
answer in ~20ms, so 2s is generous.
Verified by reproducing the reviewer's scenario: force the fallback, put all 4
wedged targets FIRST, and disguise the superhuman tab's URL so preference
ordering cannot rescue it. Before: yielded 0 in 10002ms. Now: advances past all
4 and returns 5 cookies in 8073ms. (4 of 8 live page targets are currently
wedged — this is common, not exotic.)
2. The multi-candidate generator was unreachable (MEDIUM).
readSessionCookieHeader returned the FIRST yielded header, so `seen` could never
dedupe and no page candidate after the first ever ran. Worse, its docstring
claimed the caller "gates on AUTHENTICATION", while the KNOWN LIMITATION block
directly below admitted it gates on non-emptiness — two contradictory
explanations in one file. Replaced with the plain sequential read it actually
was, keeping one honest limitation note.
Also fixed: isSessionRefreshHealthy's docstring claimed exchanging cookies for a
CSRF token was "a real proof the session is live". It is not — measured, that
endpoint returns 200 and a csrfToken with bogus cookies and with no Cookie
header at all. It now says what it actually checks (cookies are reachable) and
that false positives are possible when signed out.
Confirmed correct by the reviewer and left alone: deadline arithmetic at the
0-boundary, cdpTimeoutMs validation across every dangerous input, generator
cleanup on early return (no websocket leak), and Storage-guard removal.
Accepted, not fixed: COOKIE_HOSTS admits mail.* cookies though requests only go
to accounts.* — over-broad, but faithfully reproduces the old
Network.getCookies({urls}) scoping, and narrowing it risks dropping cookies the
backend needs. Live store has no host-only mail.* cookies. BACKEND_PATH is a
prefix so a cookie pathed to the exact endpoint would be under-admitted; all 8
live cookies are Path=/.
Tests 445 pass / 6 fail (6 pre-existing live E2E --attach). Hang regressions
green. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
Codex's final adversarial pass found two the subagent missed. 1. Cookie ordering (medium). RFC 6265 §5.4 orders matching cookies by descending path length; Storage.getCookies returns the store in unspecified order and we preserved it. With `session` at both `/` and `/~backend` we emitted `session=root; session=backend`, where a browser sends the more specific first. A backend taking the first occurrence would authenticate with the wrong value — the same "let the server pick" failure the path filter was added to prevent, one level down. Now sorted longest-path-first. Mutation-verified: removing the sort fails the new test. 2. Late-landing attach leaked a websocket (low). withTimeout only stops waiting — it cannot abort a connect. A slow handshake completing after we gave up left a live websocket with no reference, one per attempt. attachWithTimeout now closes the client if it arrives after the timeout. Codex confirmed correct and left alone: the per-target cap's zero-budget behaviour, domain matching, path boundary matching, generator finalization (client closed on early return), no unhandled rejections, and browser/page route equivalence beyond the documented wrong-jar limitation. Test coverage remains thinner than ideal — the CDP paths themselves (attach, fallback-after-empty, ordering preference, the 2s/10s bounds) are exercised live rather than in unit tests, since faking them well enough to be meaningful means faking chrome-remote-interface wholesale. Noted rather than hidden. Tests 446 pass / 6 fail (6 pre-existing live E2E --attach). Hang regressions green. tsc --noEmit clean. Live: 5 cookies in 28ms, real CSRF exchange against accounts.superhuman.com returns true. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
Adversarial review: 3 rounds (subagent + codex each), convergedBoth reviewers, run independently, found real bugs in my code every round. Summary of what they caught and what changed. Round 1 — both converged on the same critical flawThe fallback was gated on empty, not on wrong. Codex additionally caught that Fixed: bounded the attach; one global deadline; RFC 6265 path scoping (the domain-only filter admitted cookies the old Reverted: gating on authentication. Two reasons, both measured — That also means the pre-existing Round 2 — my round-1 fix had traded one bug for anotherEach per-target call got Fixed with Also: the multi-candidate generator was unreachable dead code (nothing consumed past the first yield, so Round 3 — codex found two the subagent missed
Confirmed correct and left alone: deadline arithmetic at the 0-boundary, Accepted, not fixed (documented in-code)
Final state446 pass / 6 fail (the 6 are pre-existing live E2E Still unverified: Electron (9252). No Superhuman.app on this Linux box. The fallback exists precisely so that if Electron does not serve |
Ships #28: session cookies are read from the browser-level CDP target rather than an arbitrary page, so a busy renderer can no longer hang or silently fail token refresh. Falls back to page targets, so the Electron deployment's worst case is the previous behaviour. Bumps package.json and the hardcoded cli.ts VERSION together — they are checked against each other and drift silently otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK
Follow-up to 635b763. That commit is right about why it reads cookies — the extension's own targets never answer CDP — but the fallback it uses to read them rests on a premise my measurements contradict.
The premise
The measurement
Probing all six page targets in a live browser,
Network.getCookieshung indefinitely on one while the other five answered in milliseconds:www.amazon.com/s?k=...wrds-www.wharton.upenn.edu/pages/get-d…music.youtube.com/playlistread.readwise.io/laterweb.morgen.so/mail.superhuman.com/…And which target hangs drifts. Three hours earlier
music.youtube.comhung reliably (twice, with and withoutNetwork.enable) whilewrds-wwwwas fine — the exact reverse. It tracks a busy renderer, not a site. So no page target can be assumed responsive, andpages[0]is simply an arbitrary tab.Why it matters
With no Superhuman tab open,
?? pages[0]attaches to whatever is first. If that renderer is wedged the read never returns — and a hang is not a rejection, so the surroundingtry/catchcannot turn it into the documentednull. The caller just stops. On-demand refresh then fails exactly as it did before this module existed — the attachment-download 401 it was written to prevent.Latent today only because a Superhuman tab happens to be open, so the preferred branch wins. It is not fixed, just unrolled.
The fix
Read from the browser-level target via
Storage.getCookies: no renderer to wedge, and no Superhuman tab — or any tab — need be open. The read is bounded (CDP_TIMEOUT_MS, default 10s) so a future stall degrades tonullinstead of hanging.Two details worth a reviewer's eye:
CDP.Version()'swebSocketDebuggerUrl.target: "browser"does not work — chrome-remote-interface treats a target string as an id and looks it up inCDP.List(), which never contains the browser (it TypeErrors). UsingCDP.Version()rather thanfetch()also keeps discovery on the library's own transport, so the existing "no network call without a browser" test still holds. My first draft usedfetch()and broke that test — correctly.Storage.getCookiestakes nourlsfilter and returns the whole store, so I scope it with RFC 6265 host-matching against the two hosts the backend calls authenticate against. A plain*.superhuman.comfilter is wrong: it also picks upmedia.superhuman.com's owndevice-idand sends duplicate cookie names with different values (I hit this — it still worked, which is exactly why it would have been a nasty latent bug).Verification
Live, against the real browser and the real backend:
name=valuepairs as the old page-target path — set-identical; order differs, which RFC 6265 does not constrain and the backend acceptsisSessionRefreshHealthy()→ true — a real CSRF exchange againstaccounts.superhuman.com, i.e. the backend accepts the header this buildsnull, unchanged432 pass / 6 fail — exactly matching
main(the 6 are pre-existing live E2E--attachfailures, unrelated).tsc --noEmitclean — this repo does gate on tsc.The new "never attaches to a page target" test is mutation-verified: reverting discovery to
CDP.Listpage enumeration fails it.Note
Based on
main(635b763). I leftfix/account-sync-chrome-extension(#27) untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK