Skip to content

fix(browse): state save|load carries localStorage, so a saved login restores - #2853

Closed
DrBanks82 wants to merge 1 commit into
garrytan:mainfrom
DrBanks82:browse-state-save-localstorage
Closed

DrBanks82 wants to merge 1 commit into
garrytan:mainfrom
DrBanks82:browse-state-save-localstorage

Conversation

@DrBanks82

Copy link
Copy Markdown

Why (in your own words)

browse state save <name> saves cookies and URLs but not localStorage. That makes it useless for the auth it looks like it handles: Supabase, Firebase and most SPA auth keep the session in localStorage, not in a cookie. So state load replays hundreds of cookies, prints a success line, and hands back a signed-out browser. Nothing errors.

I hit this during real work tonight. A saved operator session wouldn't restore, and the symptom — landing on /auth — looks exactly like an expired token, so I went down the wrong path twice: first "the state file is stale, re-save it" (re-saved from a live signed-in daemon, same result), then "a stuck Chromium is holding the profile, kill it and sign in again". The second one was about to throw away a working signed-in browser to fix a save that was never going to work. The actual cause only showed up when I dumped the file and found no origins key at all.

The omission was deliberate, behind // V1: cookies + URLs only (not localStorage — breaks on load-before-navigate). That reasoning is stale: BrowserManager.restoreState navigates each tab to its saved URL first and applies storage after, so there's no load-before-navigate window. The sibling persistence path (session-persist.ts, #778) already persists per-tab storage through that same restore. Only the manual named-state path was left behind.

Live evidence

Before — the file has no storage at all:

$ python3 -c "import json;d=json.load(open('~/.gstack/sessions-browse/browse-states/qa-operator.json'));print(list(d.keys()))"
['version', 'savedAt', 'cookies', 'pages']

$ python3 -c "... print('origins' in d)"
NO origins key anywhere -> localStorage is NOT saved

Before — save from a live signed-in daemon, load into a fresh one, still signed out:

$ browse state save qa-operator
State saved: .../qa-operator.json (397 cookies, 1 pages)
⚠️  Cookies stored in plaintext. Delete when no longer needed.

$ browse state load qa-operator          # fresh daemon
State loaded: 397 cookies, 1 pages       # <- reports success
$ browse goto https://<app>/wylie
Navigated to https://<app>/wylie (200)
$ browse url
https://<app>/auth                       # <- signed OUT

$ browse js "localStorage.getItem('sb-<proj>-auth-token') ? 'present' : 'NO_AUTH_KEY_IN_LOCALSTORAGE'"
NO_AUTH_KEY_IN_LOCALSTORAGE

After — localStorage round-trips between two separate daemons:

$ browse goto https://example.com
Navigated to https://example.com (200)
$ browse js "localStorage.setItem('gstack_probe','round-trip-ok'); localStorage.getItem('gstack_probe')"
round-trip-ok

$ browse state save lstest
State saved: .../lstest.json (0 cookies, 1 pages, 1 localStorage keys)
⚠️  Cookies and localStorage stored in plaintext — this file can contain auth tokens. Delete when no longer needed.

# --- different daemon, different state file ---
$ browse state load lstest
State loaded: 0 cookies, 1 pages, 1 localStorage keys
$ browse js "localStorage.getItem('gstack_probe') || 'MISSING'"
round-trip-ok

$ python3 -c "...print page storage keys..."
page storage localStorage keys: [['gstack_probe']]

After — a pre-fix file (no storage key) still loads, and now says so:

$ browse state load oldshape
State loaded: 404 cookies, 1 pages, 0 localStorage keys

That 0 localStorage keys is the second half of the fix. It's the visible tell that a given file cannot restore a login, instead of that fact surfacing later as an unexplained sign-in redirect.

Tests — red on the old tree for the right reason, then green:

# with only meta-commands.ts reverted (helper still exported, so this is an
# assertion failure, not a compile break)
$ bun test browse/test/state-save-localstorage.test.ts
error: expect(received).toContain(expected)
Expected to contain: "1 localStorage keys"
Received: "State saved: .../rt.json (0 cookies, 1 pages)\n⚠️  Cookies stored in plaintext..."
(fail) state save|load round-trip (real Chromium) > localStorage written before save is readable after load
 4 pass, 2 fail

# with the fix
$ bun test browse/test/state-save-localstorage.test.ts
 6 pass, 0 fail, 15 expect() calls  [16.01s]

# sibling suite, whose internals this refactors
$ bun test browse/test/session-persist.test.ts
 17 pass, 0 fail, 47 expect() calls  [10.74s]

Scope

  • Changed:
    • browse/src/meta-commands.tsstate save persists per-tab storage; state load restores it instead of hardcoding storage: null; both success lines report the localStorage key count; the plaintext warning now says the file can contain auth tokens, because it now can.
    • browse/src/session-persist.ts — extracted sanitizeTabStorage() and pointed deserializeSessionState at it, so the persistence restore path and state load validate identically. String keys and string values only: restore hands this to localStorage.setItem inside page.evaluate, so a tampered file must not get a non-string coerced in. Same single-source-of-truth rule the file already applies to isInternalCookieDomain / filterSessionCookies.
    • browse/test/state-save-localstorage.test.ts — new. Units for the validator, a real-Chromium round trip through handleMetaCommand, and a backward-compat case.
  • Verified live by: driving two separate daemons through save → load → read on a real page, plus the original failing auth case that started this; the new suite red-then-green with meta-commands.ts reverted; session-persist.test.ts still green.
  • Did NOT test: Windows (the 0600 assertion is already skipped there, matching session-persist.test.ts); sessionStorage end-to-end — it rides the same field and validator as localStorage and is covered by units, but I only exercised localStorage against a real login; headed mode, which keeps its own persistent Chromium profile and never used this path.

Liveness proof (required)

Pending — the repo owner will attach it. This PR was prepared by Claude Code in a session driven by @DrBanks82; I'm not going to fabricate a screenshot whose stated purpose is confirming a human is behind the PR. Opening as a draft until it's attached.

Checklist

  • Liveness screenshot attached: GSTACK PR typed live into a real surface (not edited onto the image) — pending, see above
  • This is not a generated-file-only diff (I edited the source/template and regenerated)
  • No ETHOS.md edits, and no changes to voice / founder perspective / YC references
  • New public command / external service / host adapter has an accepted issue linked (or N/A) — N/A, no new command; this fixes an existing one
  • Linked issue or reproduction: follow-on to [browse] Auth state is lost across separate invocations; next call restarts on about:blank with stale state #778, which fixed this class for the automatic persistence path but not for state save|load. Full reproduction in Live evidence above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D4gvxB6ytnEuPkpVBtK2bg

…estores

`browse state save <name>` wrote cookies and URLs only. Every
token-in-localStorage login — Supabase, Firebase, most SPA auth — was
therefore unrestorable: `state load` replayed hundreds of cookies, printed a
success line, and handed back a signed-OUT browser. Nothing errored. The
failure surfaced later as an unexplained redirect to a sign-in page, which
reads as an expired session rather than a save that never captured the
session at all.

The omission was deliberate, behind the comment "not localStorage — breaks on
load-before-navigate". That reasoning is now stale: BrowserManager.restoreState
navigates each tab to its saved URL FIRST and applies storage after, so there
is no load-before-navigate window. The sibling persistence path
(session-persist.ts, garrytan#778) already persists per-tab storage through that same
restore; only the manual named-state path was left behind.

- meta-commands.ts: `state save` persists per-tab `storage`; `state load`
  restores it instead of hardcoding `storage: null`. Both success lines now
  report the localStorage key count, so "0 localStorage keys" is a visible
  tell that a file cannot restore a login rather than a silent redirect later.
  The plaintext warning now says the file can contain auth tokens, because it
  now can.
- session-persist.ts: extracted `sanitizeTabStorage()` and pointed
  `deserializeSessionState` at it, so the persistence restore path and
  `state load` validate the same way — the single-source-of-truth rule that
  file already applies to `isInternalCookieDomain`/`filterSessionCookies`.
  String keys and string values only: restore hands this to
  `localStorage.setItem` inside `page.evaluate`, so a tampered file must not
  get a non-string coerced in.

Pre-fix state files have no `storage` key and load exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D4gvxB6ytnEuPkpVBtK2bg
@trunk-io

trunk-io Bot commented Sep 12, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@DrBanks82

Copy link
Copy Markdown
Author

Closing — this was scope drift on my end. The fix is running locally and upstreaming it isn't worth the maintainer's review time. Details stay in the thread if anyone hits the same thing: state save writes {version, savedAt, cookies, pages} with no origins key, so a localStorage-based login (Supabase, Firebase) can never be restored from a saved state.

@DrBanks82 DrBanks82 closed this Sep 12, 2026
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