2026 09 30 site data design - kubed-io/selenium-flow GitHub Wiki
Design record for saving and restoring cookies, localStorage and
sessionStorage, and for the history of where a session has been. Round 1
was written 2026-09-30 and shipped in #49 and #50. Round 2 was written
2026-10-01, after Dr K saw round 1 live. This round lives in
docs/superpowers/ and not in the saga.
Status: round 2 is settled. The BiDi spike passed (below) and every question is ruled. The Penpot file Admin UI holds the drawing on the pages Session · Site data and Session · History, in version Site data design, round 2 — History rows fold; the session tab bar on every page gains History.
Seen live, round 1 listed every site any secret is allowed on, in every session, whether the session went there or not. Rows were the union of saved data and secrets, so the tab answered "what could this session use" rather than "where has it been". Two more things came out of the discussion that followed:
- A save overlaid storage. The cookies were replaced on every save, but each origin's storage was added beside the last. A sign-out never removed the storage saved before it. browser-use has the same flaw, and Playwright does not.
- A save only reached one site's storage. Only the page the browser is on can read its own storage, so an agent signed in to two apps had to save on each. A save on the second one quietly left the first one out.
Round 2 adds a history, makes a save a full snapshot, writes a restore straight into the browser, and splits the tab in two: History and Site data.
-
As an agent, I restore a session's data so that the browser remembers
me. One
save_site_dataafter signing in is enough, however many sites the sign-in touched. - As an admin, I see where the agent has been and what it saved, so that I have an audit trail and the context to follow while it works.
| Not built | Why |
|---|---|
| A capture on every call | Dr K: overkill, and every call would pay for it. The history records URLs, which every call already writes. |
| Capture only when a browser ends | A reap gives no warning, so it is not dependable. |
| A browser profile on the Grid | Nodes are disposable and KEDA-scaled; a profile holds one browser; credentials would sit on shared disks. Browserbase, Steel and Hyperbrowser do this, and none of them can show a per-site view. |
| Knowing which page set a cookie | No browser records it, and BiDi does not expose it (research, 2026-10-01). A cookie is shown under its own domain, which is the only truth the jar holds. |
| Grouping sites by registrable domain | Chrome and Firefox group by eTLD+1. This homelab lives under one domain, so every app would be one row. |
| A setter tool (cookie, localStorage, sessionStorage) |
execute_script covers storage and ordinary cookies; setting an httpOnly cookie has no user yet. |
| An agent-facing delete for one site | Forgetting a site is an operator action. restore_site_data=false is the agent's clean start. |
| Sharing between sessions | Site data and history are session-bound; nothing reads another session's. |
| IndexedDB | Playwright made it opt-in in 1.51; nothing here needs it yet. Named in the skill as a limit (Firebase Auth lives there). |
| sessionStorage for every origin | It belongs to one tab, so only the current tab's current origin can be read. Playwright skips it entirely; we keep the one we can reach, because MSAL and this server's own admin page sign in through it. |
Site data — the browsers' own term for these three stores together
("Cookies and site data" in Chrome and Firefox, the Clear-Site-Data
header). The tab keeps the name and shows only the snapshot; History is
its own tab beside it.
Classic WebDriver only sees the site the browser is on. WebDriver BiDi is the W3C standard half of WebDriver — not CDP — and works on Chrome and Firefox alike. Round 2 uses three of its modules:
-
storage.getCookies/storage.setCookieread and set every cookie, from any page, httpOnly included (proved 2026-09-30, both browsers). -
browsingContext.createopens a spare tab for the save and restore below, and closes it after. -
network.addIntercept+network.provideResponseanswer that tab's requests with an empty page, so the tab can stand on any origin without the site loading. This is Playwright's own method forstorageState. Firefox supports a response body from version 129.
Every call reattaches. The server holds no driver between calls.
Grid.bidi(session_id) binds a reattached driver whose webSocketUrl is
ws://<grid>/session/<id>/se/bidi, and closes the socket when done.
Run against this Grid: Chrome 152 and Firefox 155. Throwaway; nothing kept.
| Check | Chrome | Firefox |
|---|---|---|
| Write another origin's localStorage from a spare tab, then load it for real | pass | pass |
| Read another origin's localStorage from a spare tab | pass | pass |
| sessionStorage in the main tab before a real load | pass | pass |
| An origin with a registered service worker | the worker answers | the worker answers |
-
The site never loads in the spare tab: it stood on
the-internet.herokuapp.comwith a blank body. An unresolvable host works too, since no DNS lookup happens. -
Selenium 4.49's own BiDi API is enough, with no raw commands:
browsing_context.create(background=True), a context-scopednetwork.add_intercept,network.provide_responsewith a body, andadd_event_handler("before_request", …, contexts=[ctx]). The other event name (before_request_sent) drops the request fields, and one subscription per event means a handler is removed before the next tab. -
Time per origin, one tab reused: 30–75 ms in Chrome and 55–140 ms in
Firefox, plus about 50–100 ms to set up and tear down the tab. That needs
the client's WebSocket polling lowered from Selenium's 100 ms, which
otherwise puts a 100 ms floor under every BiDi call.
Grid.bidisets only the timeout today; round 2 lowers the polling too, which speeds up round 1's calls as well. - A service worker answers first. On an origin it controls (Squoosh), the real app loaded in the spare tab in both browsers. Chrome's intercept never saw the navigation; Firefox's saw it and then failed with "no such request". Only a CDP call got Chrome past it, and CDP is ruled out. A restore always starts in a fresh browser, which has no service worker, so restore is unaffected; only a save's read of other origins is. See open question 5.
Two fields on SessionRecord, in the session store (memory or Redis),
never on this server's disk; with the Redis store they are as durable as
Redis. Both expire with the record (session.ttl, 24 hours since the last
use). No new setting.
[
{"origin": "https://grafana.kubed.io", "url": "https://grafana.kubed.io/d/abc", "at": 1790900000.0},
{"origin": "https://keycloak.kubed.io", "url": "https://keycloak.kubed.io/realms/x/login", "at": 1790899000.0}
]- Every call that lands on a page bumps that origin to the top with its URL and time, or adds it there. A flow writes its steps' origins in one update at the end of the run, in order.
- Before a
save_site_datastep, a flow first writes the pages it reached so far, in one update and in order, so the save reads their storage; the end of the run then writes only what came after. -
record.urlis gone: the current URL ishistory[0].url. A reopen navigates there, as it did tourl. - Only what
touchrecords today enters it. A URL withheld after a secret write (§F1.24) records nothing, and so does a page with no origin (about:blank,data:). - One entry per origin. An entry older than
session.ttlis dropped on the next write;history[0]is never dropped while the record lives. At most 100 entries; the oldest goes first. - Only the top entry keeps its whole URL, as
urlalways did: a reopen goes back there. Below it a URL keeps its origin and path; a query string or fragment carries OAuth codes and reset tokens (Copilot, #51). - No migration: a record written before round 2 reads as one that has been nowhere and loses its last page once; its browser, settings and site data stand.
{
"cookies": [
{"name": "rack.session", "value": "…", "domain": "the-internet.herokuapp.com",
"path": "/", "http_only": true, "secure": true, "same_site": "lax",
"expiry": null}
],
"origins": {
"https://the-internet.herokuapp.com": {"local": {"theme": "dark"}}
},
"session": {"origin": "https://the-internet.herokuapp.com", "items": {}},
"saved_at": 1790800000.0
}- Each save replaces the whole field, so a sign-out saved after a sign-in is the state that comes back. The one thing carried over is the last storage of an origin the save could not read (below).
-
cookies— the whole jar. -
origins— localStorage for every origin in the history that has any. Origins with empty storage are left out. -
session— sessionStorage of the tab the browser was on, for its origin. - Round 1's
pending, its preload script and the tab marker are gone (see What round 2 removes). -
Size cap: 1 000 000 bytes of JSON. Over it, the storage of the origins
visited longest ago is left out until it fits, each named in
skipped. If the cookies alone pass it, the save is a 400 and the record is unchanged. - Values are never logged.
Same tool and endpoint (POST /browser/save-site-data). What happens
underneath:
- Read the whole jar.
- Read localStorage and sessionStorage from the page the browser is on.
- For every other origin in the history, read localStorage in the spare tab, one origin at a time, then close the tab.
- Replace the snapshot.
- If one origin's read fails, that origin keeps its storage from the last
snapshot, and the result says so in
skipped— a blip must not sign the agent out. - The result names what was saved, never a value:
{"url": …, "title": …, "saved": {"cookies": 14, "sites": [...origins], "skipped": []}, "uri": "session://site-data"} - BiDi down is still a 503 with nothing saved. It is still a flow step.
- Annotations unchanged: not read-only, not destructive, idempotent. A
service worker's page loading in the spare tab is a navigation, which
navigateis too; destructive is for handing the page an instruction (AGENTS.md), and a save clicks, types and runs nothing.
-
True (default): before the browser goes to its page, write in every
unexpired cookie, then every origin's localStorage through the spare tab,
then the saved sessionStorage in the main tab, which then returns to
about:blank so an open with no url never shows the stand-in page. Then
land. The browser is fully restored when
open_sessionreturns. The silent reopen after a reap does the same. - False: nothing is restored and the snapshot is deleted (Dr K). The history stays: it forgets the sign-in, not where the session has been.
-
freshkeeps its meaning.insecure=truestill restores nothing and deletes nothing, with the same hint. -
restore_site_data=falsewithinsecure=truestill deletes, as false always does. - Restore is best effort: nothing in it can fail an open.
-
open_session→"site_data": {"restored": [...hosts], "skipped": [...], "uri": "session://site-data"}— only when something was restored or skipped. -
open_session(restore_site_data=false)→"site_data": {"forgotten": 2}when there was something to forget. - The first call after a silent reopen carries the reopen's report, once.
SessionRecord.reopenedholds it untiltouchhands it to the first result from that browser. A flow carries it at run level (FlowRun.site_data); no step carriessite_dataany more. -
No arrival hint and no
waiting: every origin is in place before the open returns.
-
session://site-data— one entry per host the snapshot holds data for, counts only, the hosts the session went to first:{site, uri, cookies, storage}per row, andsaved_atat the top of the listing (one save, one time). Round 1's admin-only fields (saved,secrets,unleashed_secrets,saved_sites) and the per-rowsaved_atare gone. The history is not on the agent surface. -
session://site-data/{site}— one host in full, httpOnly values"•••". -
session://current—urlishistory[0].url;site_dataas before.
The preload script and everything that kept it honest: pending, the
waiting list and the arrival hint, site_data.preload_source, arrive,
retire, replace, Actions.retire_site_data, the stale flag,
SessionManager._hold_pending, the MARKER tab key, and the per-origin
saved_at. Most of the bugs found in #49 and #50 lived here.
History and site data are kept apart, in the model and on screen, the way a browser keeps its history apart from its cookies and storage. The session subtabs become Files · Flows · Site data · History. Neither tab stores a row: each is built per request by the admin API.
Where the session has been, joined by host to what can be used there. Drawn: page Session · History, flow History.
- The current site is on top, then the history, most recent first. One row per host, folding like Site data's rows (Dr K: consistent): the top row opens, the rest stay shut, once; after that the fold is the reader's. The head: the caret, its latest URL in bold, when it was visited and how many secrets ("1m ago · 1 secret"), and a pill with the snapshot's counts for that host ("2 cookies · 2 local", zero counts left out), which opens that host in Site data. No pill when nothing is saved for it. The top row carries no marker: it is the session card's last page.
- Opened, one line per secret allowed on that host: 🔑 name, description, one pill per key — the Secrets tab's own line. A row with no secrets has nothing to open: no caret, its place kept.
- Secrets never make a row. A site the session never landed on is not listed, whatever the secrets say.
- The tab counts the hosts listed. A session that has been nowhere shows "Nowhere yet." — the session card's own words.
-
Clear sits above the rows on the right, and only while the history
holds anything but the current origin — a second port of the current host
included, which is no row of its own; the API names those origins
(
clears), and the confirm'sgoeslists them (Copilot, #51). Its confirm, Clear history: "History only: site data and the browser are untouched.", thengoes(the hosts) andstays(the current site). There is no per-row action: the history expires on its own. - Clear keeps only the top entry: another origin of the current host (a second port) goes too, and the host's row stays.
- Later, not now: flows and runs on a row, and every path visited.
What a reopened browser gets back: the snapshot and nothing else. Drawn: page Session · Site data, flow Site data.
- Above the rows: one "saved 2m ago" pill for the snapshot on the left, and Clear on the right.
- One row per host the snapshot holds data for — the ad server, the CDN and the identity provider reached only by a redirect included, because they are restored. Hosts in the history come first, most recent first, then the rest alphabetically. No secrets here.
- A row head: its origin (the host when it has none or several), its counts ("2 cookies · 2 local · 0 session", or "3 cookies" for a cookie-only host), and Forget. Expanded as in round 1: COOKIES, LOCAL STORAGE and SESSION STORAGE ("none" when empty), per origin.
- A cookie is listed under its own domain. A parent's leading-dot cookie is listed under every row it covers, marked shared; it belongs to none.
- The tab counts the hosts listed.
-
Forget (one row) removes the host's storage and its own cookies
(
hostor.host) from the snapshot; its confirm keeps round 1'sgoes/stayslines without the secret line. Clear deletes the snapshot; its confirm, Clear site data: "3 sites — a reopened browser comes back signed out.", thengoesand the hosts. Neither touches the history or the live browser: the browser keeps what it has, and only the next browser opened comes back without it. - Empty: "Nothing saved — an agent calls
save_site_dataafter signing in." - Copy dropped: "Nothing saved. It stays listed because a secret is allowed here." and "nothing saved". Every row here has something saved.
-
GET /admin/sessions/{key}/history— the history joined with secrets and per-host snapshot counts;DELETE …/historyfor Clear. -
GET /admin/sessions/{key}/site-data— the snapshot by host, no secrets;DELETE …/site-data/{site}for Forget;DELETE …/site-datafor Clear. - Token-gated. The session row gains
history_countandhistory_revbesidesite_data_countandsite_data_rev, so each pane repaints for its own changes. -
history_revis every entry's origin and URL, in order: a new site, a return to an older one, or a new page on any row repaints History; only the clock moving does not (Copilot, #51). -
site_data_revis[saved_at, hosts]: a save moves the time, a Forget or a Clear moves the hosts (neither is a save, so neither movessaved_at).
SITE_DATA.md changes: one save after signing in covers every site the
sign-in touched; a restore is complete when open_session returns; no
arrival hint. The limits stay (IndexedDB; sessionStorage for one tab only; a
save after signing out saves you signed out).
- Unit: the history's bump, expiry, cap and withheld URLs;
urlderived fromhistory[0]; the snapshot replacing rather than merging; a failed origin keeping its last storage; the cap leaving out the oldest; the History view (current on top, secrets joining only visited hosts, snapshot counts per host); the Site data view (history hosts first, then the rest); Forget and both Clears leaving the other tab's data alone. BiDi faked atGrid.bidi. Each new test broken on purpose once. - The standing guards:
test_surfaces, the declared response shapes, the wiki regenerating. - Fixtures use
example.com-style hosts, never a real person's domain — the drawing's hosts included. - The integration flow stays: sign in to the admin page, save, and the Site data tab lists the token's sessionStorage key.
- Live before the PR: Chrome and Firefox, two apps signed in, one save, a real end and reopen, both signed in, timed.
A save meets a site with a service worker. The spare tab is answered by the site's own worker, so the real app runs for a moment in a hidden tab, and an app that rotates its sign-in token there can leave the saved one stale.
W3C only, with a fallback (Dr K). The spare tab serves a page with a
marker; when the marker is missing, the tab closes without reading, that
origin keeps its storage from the last snapshot, and skipped says
{site, reason: "a service worker answered: save while on this site"}. A save
made while on that site reads its storage directly, as always. The worst case
is round 1's behaviour for that one site, and cookies are never affected.
Why not CDP here: service workers are W3C (the spec is a Candidate
Recommendation Draft, implemented by every engine), but BiDi has no
switch to bypass one — Puppeteer lists setBypassServiceWorker as
unsupported over BiDi, and the nearest BiDi issue (#846, only a "from
service worker" flag) is still in discussion. CDP's switch is
Chromium-only: Firefox turned CDP off by default in 129 and Selenium
dropped it for Firefox in February 2025. So CDP would fix Chrome alone,
Firefox would still need the skip, and the rule "no CDP" would buy two
code paths for one narrow case. If a real site needs it, a single
Chromium-only call can be added later without undoing anything.
-
Found in CI, fixed (#51): an intercept that outlived its tab blocked the next real page load until the page-load timeout. The integration flow after
save-site-datahung five minutes on its reopen. The intercept now matches onlySPARE_PATH(/__selenium-flow-spare__, on any origin), so a leaked one can never hold up a real page, and teardown removes the intercept before its handler. Still next: report a failed removal, and test never-raises / navigate-raises; a failed unsubscribe leaves Selenium's subscription entry. -
No overall time budget on the spare-tab loops: each origin is bounded only by
BIDI_TIMEOUT(5 s), so a systematic failure costs 5 s per origin (up to ~8 min for 100). Next: stop at the first timeout-class failure, or a total deadline. -
A call landing between
visited()and the save's write can drop a newly visited origin's previous storage (not read, not kept). Next: keep storage for history origins the save did not attempt. -
The size-cap loop re-serialises the snapshot per eviction inside the store fn. Next: per-origin sizes once.
-
Every touch rewrites a record that can hold up to 1 MB of site data. Later: site data under its own store key.
-
A flow step that fails and is continued past (
onError: continue) can land on a safe page, but only successful steps that moved the page enter the history: that origin is missed, and so is a successful step that stays on it, since the page did not change. A later save in that run does not read it. Next: track the run's safe pages apart from the report's "page changed" rule, inrun.pyandrun_foralike, so the save flush stays aligned (Copilot, #51). -
History's secrets are joined per request, but its revision carries no catalogue state: a secret added, removed or re-leashed while the tab is open shows on the next navigation or reload. Next: a catalogue revision in the session row (Copilot, #51).
Round 1:
- Dr K: explicit save, not automatic capture; session-bound; httpOnly values masked; lifetime of the session; always save all three stores; no setter; the admin view is read-only with Forget; secret rows show key names.
- Planning: BiDi reattached per call; the restore pill dropped; the integration flow proves save, not restore.
Round 2 (2026-10-01):
- Dr K: the history is where the session went, not a list of what it could
use; secrets join a row and never make one; history is per session and
expires; the two "nothing saved" lines go; the
save_site_datainterface stays; keep sessionStorage; the current URL becomes the top of the history. - Dr K: History is its own tab, a join view by host of the history, the secrets and the snapshot. Site data is only the snapshot. The two are separate in the model and meet only in the admin view.
- Dr K: Forget and Clear change the session's store, never the live browser. A save after a Forget saves the browser again, forgotten site included: a save is intentional, and wiping the browser can be added later rather than taken away.
- Dr K: one row per host with its latest URL is the audit trail for now; deeper history (flows, runs, paths) is a later discussion.
- Dr K:
restore_site_data=falsedeletes the snapshot and keeps the history. - Research: a save is a full snapshot (Playwright), not a merge (browser-use's flaw); localStorage is read for every visited origin through a spare tab with network interception; cookie provenance is unknowable, so cookies show under their own domain.
- Dr K: History rows fold like Site data's.
- Proposed, and taken as the simple rule when Dr K moved on to the PR: a
page with no origin (
about:blank, soopen_session(fresh=true)) records nothing. The last page stays the previous site until the next real page, and a reap in that gap reopens there. - Dr K: service workers are handled W3C only, with the fallback in Service workers at save time; no CDP.
- No browser history API: WebDriver and BiDi can step back and forward but cannot list where a tab has been, and a browser's own history dies with it on a reap. The session records its own, from the URL each call already reports.