Skip to content

fix: read session cookies from the browser target, not an arbitrary page#28

Merged
edwinhu merged 5 commits into
mainfrom
fix/session-refresh-browser-level
Jul 16, 2026
Merged

fix: read session cookies from the browser target, not an arbitrary page#28
edwinhu merged 5 commits into
mainfrom
fix/session-refresh-browser-level

Conversation

@edwinhu

@edwinhu edwinhu commented Jul 16, 2026

Copy link
Copy Markdown
Owner

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

Uses a page target: Network.getCookies reads the shared browser cookie store [...] and unlike the extension's own targets, page targets respond to CDP reliably.

const target = pages.find(t => t.url.includes("superhuman.com")) ?? pages[0];

The measurement

Probing all six page targets in a live browser, Network.getCookies hung indefinitely on one while the other five answered in milliseconds:

target result
www.amazon.com/s?k=... answers
wrds-www.wharton.upenn.edu/pages/get-d… hangs
music.youtube.com/playlist answers
read.readwise.io/later answers
web.morgen.so/ answers
mail.superhuman.com/… answers

And which target hangs drifts. Three hours earlier music.youtube.com hung reliably (twice, with and without Network.enable) while wrds-www was fine — the exact reverse. It tracks a busy renderer, not a site. So no page target can be assumed responsive, and pages[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 surrounding 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 — 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 to null instead of hanging.

Two details worth a reviewer's eye:

  • 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 (it TypeErrors). Using CDP.Version() rather than fetch() also keeps discovery on the library's own transport, so the existing "no network call without a browser" test still holds. My first draft used fetch() and broke that test — correctly.
  • Storage.getCookies takes no urls filter 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.com filter is wrong: it also picks up media.superhuman.com's own device-id and 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:

  • 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
  • isSessionRefreshHealthy() → true — a real CSRF exchange against accounts.superhuman.com, i.e. the backend accepts the header this builds
  • dead port → null, unchanged

432 pass / 6 fail — exactly matching main (the 6 are pre-existing live E2E --attach failures, unrelated). tsc --noEmit clean — this repo does gate on tsc.

The new "never attaches to a page target" test is mutation-verified: reverting discovery to CDP.List page enumeration fails it.

Note

Based on main (635b763). I left fix/account-sync-chrome-extension (#27) untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UZvrft29zMGgks5abHVtVK

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
edwinhu and others added 4 commits July 16, 2026 16:06
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
@edwinhu

edwinhu commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review: 3 rounds (subagent + codex each), converged

Both 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 flaw

The fallback was gated on empty, not on wrong. Storage.getCookies reads the default browser context; a page can live in another (incognito, or an Electron webPreferences.partition). A populated-but-stale default jar was returned and the page path never ran — so my "worst case on Electron is the previous behaviour" claim was false.

Codex additionally caught that await CDP(...) sat outside withTimeout — so "both routes are bounded" was also false.

Fixed: bounded the attach; one global deadline; RFC 6265 path scoping (the domain-only filter admitted cookies the old Network.getCookies({urls}) excluded); CDP_TIMEOUT_MS validation (parseInt("garbage") → NaN → setTimeout(fn, NaN) fires immediately → every call "times out" → silent stale token); 13 real unit tests for logic that had none.

Reverted: gating on authentication. Two reasons, both measured — sessions.getCsrfToken is not an auth check (returns 200 + a csrfToken with bogus cookies and with no Cookie header at all), so the only real proof is sessions.getTokens; and looping that over candidates broke three read-hang regression tests — the suite guarding this exact bug class. A real regression for a theoretical fix is a bad trade. Documented as a known limitation instead.

That also means the pre-existing isSessionRefreshHealthy docstring was wrong ("a real proof the session is live"). Corrected — doctor can false-positive when signed out.

Round 2 — my round-1 fix had traded one bug for another

Each per-target call got deadline.remaining(), i.e. the whole budget. So the first tab that hangs ate all 10s and the deadline skipped every later candidate. The sweep could only advance on a fast failure — never on a hang, the one mode it exists to survive. Reviewer measured: yielded=0 in 10002ms while three responsive tabs sat further down.

Fixed with min(remaining, PER_TARGET_CAP_MS=2s). Reproduced their scenario to prove it: forced the fallback, put all 4 wedged targets first, disguised the superhuman tab so ordering could not rescue it → advances past all 4, returns 5 cookies in 8073ms. (4 of 8 live page targets are wedged right now — this is common, not exotic.)

Also: the multi-candidate generator was unreachable dead code (nothing consumed past the first yield, so seen never deduped), and its docstring contradicted the limitation note below it. Replaced with the plain sequential read it actually was.

Round 3 — codex found two the subagent missed

  • Cookie ordering: RFC 6265 §5.4 wants descending path length; I preserved Storage.getCookies' unspecified order. session at / and /~backend emitted the wrong one first. Mutation-verified fix.
  • Late-landing attach leaked a websocket: withTimeout stops waiting but cannot abort a connect. Now closed if it arrives late.

Confirmed correct and left alone: deadline arithmetic at the 0-boundary, cdpTimeoutMs across every dangerous input, generator finalization, Storage-guard removal, no unhandled rejections.

Accepted, not fixed (documented in-code)

  • Wrong-jar: the right fix is enumerating browserContextId from CDP.List and reading each via Storage.getCookies({ browserContextId }) — no page attach, no sweep, no cost when only the default context exists. Follow-up.
  • COOKIE_HOSTS admits mail.* though requests go only to accounts.* — over-broad, but faithfully reproduces prior scoping; narrowing risks dropping needed cookies. No host-only mail.* cookies live.
  • BACKEND_PATH is a prefix, so a cookie pathed to the exact endpoint under-admits. All 8 live cookies are Path=/.

Final state

446 pass / 6 fail (the 6 are pre-existing live E2E --attach, identical on main). Hang regressions green. tsc --noEmit clean. Live: 5 cookies in 28ms; isSessionRefreshHealthy — a real CSRF exchange against accounts.superhuman.com — returns true; forced page-fallback agrees with the browser route.

Still unverified: Electron (9252). No Superhuman.app on this Linux box. The fallback exists precisely so that if Electron does not serve Storage on its browser endpoint, the worst case is the old behaviour rather than a break — but that path has not been executed. Worth one run on the Mac before merge.

@edwinhu
edwinhu merged commit 79811ad into main Jul 16, 2026
@edwinhu
edwinhu deleted the fix/session-refresh-browser-level branch July 16, 2026 20:57
edwinhu added a commit that referenced this pull request Jul 16, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant