Skip to content

2026 09 30 site data

Kelly Ferrone edited this page Oct 10, 2026 · 1 revision

Site data Implementation Plan

Round 2 — history and snapshots (2026-10-01)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: One save_site_data keeps the cookies and storage of every site the session has been to, as one snapshot that replaces the last; every browser opened for the session has all of it back before open_session returns; the admin UI shows where the session has been (History) apart from what it saved (Site data).

Architecture: The record gains history — one entry per origin, newest first — and url becomes history[0].url. A spare background tab, whose every request a BiDi network intercept answers with a marked blank page, can stand on any origin: a save reads each history origin's localStorage there; a restore writes it there before the first page, then sets sessionStorage in the main tab the same way. site_data.snapshot replaces round 1's merge, and the preload script with everything that kept it honest is deleted. A silent reopen's report waits on the record (reopened) until touch hands it to the first result. The admin API serves a History view (the history joined by host with secrets and snapshot counts) beside the Site data view (the snapshot by host).

Tech Stack: as round 1 — Python 3.10–3.14, Selenium 4.49 BiDi (storage, browsing_context, network, script), FastMCP, Starlette, Svelte 5 + vitest.

Spec: docs/superpowers/specs/2026-09-30-site-data-design.md, round 2 — read it before your task; it is the authority. Round 1 (below the rule) is the record of how the code got here; where the two disagree, the spec and this round win.

Branch: site-data-history (checked out). Spike: passed 2026-10-01 in Chrome 152 and Firefox 155 (spec, The spike); $S/spike/spike.py is the throwaway that proved the Selenium calls used in Task 3.

Penpot: file Admin UI, pages Session · History and Session · Site data, version Site data design, round 2 — History rows fold. Tasks 9 and 10 quote its copy exactly; the hosts in it are not to be copied into fixtures.

Global Constraints

  • Names stay: save_site_data, restore_site_data, session://site-data, session://site-data/{site}, the hint key site_data, the tab Site data. New: the record fields history and reopened, the tab History, the admin routes …/history.
  • No backcompat (one user, Dr K): old shapes are purged, never migrated. A record written before round 2 reads as one that has been nowhere — its url key is ignored, its browser, settings and site data stand. Old site-data shapes are read defensively (.get) and replaced whole by the next save.
  • Plain W3C WebDriver; BiDi only for site data; no CDP. BiDi is reached through Grid.bidi(session_id) and, for a tab, browser.spare_tab.
  • Values are never logged and never returned; an httpOnly value reads "•••" on every surface. Site data and history live on the record in the session store, never on this server's disk.
  • Every record write is store.update(key, fn) (or upsert): fn gets the record as it is at write time, only computes, and may run twice (Redis retries it). Slow work — BiDi, the Grid — happens outside fn.
  • Errors: a refused request is a ValueError (400) or errors.NotFound (404); a dead BiDi channel at save is errors.BidiUnavailable (503). errors.py decides; admin handlers answer through refused(exc, what).
  • House style (AGENTS.md): comments earn their lines — the non-obvious why, terse; docstrings in mcp/tools.py are prompts; every browser action is a tool and an endpoint (tests/test_surfaces.py); every response shape is declared in spec/schemas.py; UI copy is terse — only the drawn lines.
  • Never write a real person's name or domain in the repo, fixtures included: hosts are example.com-style, the user is Dr K.
  • Commands, from the repo root (S is the scratchpad holding deps/ and tools/):
S=/tmp/claude-1000/-projects-cluster/2222ebcd-ef22-431d-92ea-d22a6044b628/scratchpad
# The unit suite. Baseline before Task 1: 1530 passed. The deselected test fails in the pod
# only, because its subprocess lacks -S; the wiki command below is the real check here.
PYTHONPATH=$S/deps:. python3 -S -m pytest -q -m "not integration" tests \
  --deselect tests/test_wiki.py::test_the_generated_pages_are_current
# One file.
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py
# Lint. If $S/tools/bin/ruff is missing: python3 -m pip install --target $S/tools ruff (CI runs it too).
$S/tools/bin/ruff check kubed tests scripts
# The wiki, the pod-safe way.
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py --check
# The UI.
cd ui && npx vitest run && npm run -s check && npm run -s lint && cd ..

A script run by hand (not -m pytest) needs the repo first — PYTHONPATH=.:$S/deps — or it imports a stale kubed from deps.

  • Deleting tests by name. Several tasks remove round 1 tests that only covered what this round deletes. Write this helper once and use it as each task says:
mkdir -p $S && cat > $S/drop.py <<'EOF'
"""Delete top-level functions, classes and assignments from a test module by
name, with their decorators, then any `# ---- section` header left with
nothing under it. Usage: python3 drop.py FILE NAME..."""

import ast
import pathlib
import re
import sys

path, names = pathlib.Path(sys.argv[1]), set(sys.argv[2:])
source = path.read_text()
lines = source.splitlines(keepends=True)
found, spans = set(), []
for node in ast.parse(source).body:
    name = getattr(node, "name", None)
    if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name):
        name = node.targets[0].id
    if name in names:
        found.add(name)
        start = min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])])
        spans.append((start - 1, node.end_lineno))
missing = names - found
if missing:
    sys.exit(f"not in {path}: {', '.join(sorted(missing))}")
for start, end in sorted(spans, reverse=True):
    del lines[start:end]
text = "".join(lines)
text = re.sub(r"(?m)^# ---- [^\n]*\n(?:[ \t]*\n)*(?=# ---- |\Z)", "", text)
text = re.sub(r"\n{4,}", "\n\n\n", text).rstrip() + "\n"
path.write_text(text)
print(f"{path}: dropped {len(found)}")
EOF

It exits non-zero naming any name it did not find; a task's list is exact, so that is a stop-and-look.

  • The wiki is generated from the tool docstrings and the spec. Tasks 4–8 change both, so generate_wiki.py --check reports stale pages from Task 4 on. Task 11 regenerates it once; do not regenerate earlier.
  • Never run the integration tests in the pod, and never against the live Grid inside a task: the live check is the controller's, after Task 11.
  • Each new test is broken on purpose once — the step says how — seen to fail, then restored.
  • Commit messages end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (the second -m in every commit step). Never push.

File Structure

File Responsibility in round 2
kubed/selenium_flow/session/store.py SessionRecord.history (+ url property, at, history_cleared), SessionRecord.reopened (+ delivered), HISTORY_CAP.
kubed/selenium_flow/session/sessions.py touch records pages and hands over a reopen's report; remember keeps the history; resolve notes the report; settle stores a snapshot; visited. The pending note is gone.
kubed/selenium_flow/flows/api.py, flows/run.py A run writes its steps' pages in one touch and carries a reopen's report at run level; the per-step arrival hint goes.
kubed/selenium_flow/core/browser.py BIDI_INTERVAL on Grid.bidi; SPARE_MARKER, SPARE_PAGE, ServiceWorkerAnswered, spare_tab.
kubed/selenium_flow/core/site_data.py capture reads every history origin; snapshot replaces merge; restore writes cookies, localStorage, sessionStorage; view/views/site_view by host in history order; history_view, history_hosts; forget covers sessionStorage. Preload code deleted.
kubed/selenium_flow/core/actions.py open_session restores directly; save_site_data reads self.visited(); retire_site_data deleted.
kubed/selenium_flow/server.py Wires actions.visited.
kubed/selenium_flow/secrets.py A bound write carries a reopen's report.
kubed/selenium_flow/http/admin.py GET/DELETE …/history, GET/DELETE …/site-data, DELETE …/site-data/{site}; row fields history_count, history_rev, site_data_count, site_data_rev.
kubed/selenium_flow/mcp/resources.py, mcp/tools.py, spec/schemas.py The agent surface: listing in history order, the new save docstring, the declared shapes.
ui/src/admin/HistoryPane.svelte (new), SiteDataPane.svelte, SessionDetail.svelte, Admin.svelte, router.svelte.ts, session.svelte.ts, ui/src/lib/types.ts The two tabs.
skills/selenium-flow/references/SITE_DATA.md, SESSIONS.md, SKILL.md, wiki/notes/save_site_data.notes.md, AGENTS.md, CHANGELOG.md, wiki/ The skill, the docs, the regenerated wiki.
Tests: tests/test_history.py, tests/test_spare_tab.py, tests/site_data_fakes.py, tests/test_site_data_restore.py, tests/test_site_data_views.py, tests/test_admin_history.py, tests/test_site_data_save.py (all new); tests/test_site_data.py, tests/test_site_data_sessions.py, tests/test_admin_site_data.py, tests/test_site_data_surface.py (cut down or rewritten); ui/src/admin/HistoryPane.test.ts (new), SiteDataPane.test.ts (rewritten)
tests/integration/flows/save-site-data.yaml Unchanged: it still reads #paneSiteData section.section, .title and code.key, which Task 9 keeps.

Task 1: The history on the record

Files:

  • Modify: kubed/selenium_flow/session/store.py (imports at 27–38; constants at 56–64; SessionRecord at 91–184)
  • Modify: kubed/selenium_flow/session/sessions.py (remember 702–761, touch 763–786)
  • Modify (mechanical, url= leaves the constructor): tests/test_admin_files.py, tests/test_kept_files.py, tests/test_files_and_admin.py, tests/test_site_data_surface.py, tests/test_secrets.py, tests/test_site_data.py, tests/test_sessions.py, tests/test_site_data_sessions.py, tests/bench/test_session_store.py
  • Create: tests/test_history.py

Interfaces:

  • Consumes: core.site_data.origin_of(url: str) -> str (exists: location.origin form, "" for a page without one).

  • Produces (every later task relies on these):

    • store.HISTORY_CAP = 100
    • SessionRecord.history: list[dict] — entries {"origin": str, "url": str, "at": float}, newest first, one per origin; default []; round-tripped by to_json/from_json, a malformed entry dropped on read.
    • SessionRecord.url -> str — a property: history[0]["url"], or "". No longer a constructor argument.
    • SessionRecord.at(*urls: str | None, now: float | None = None, ttl: float = DEFAULT_TTL_SECONDS) -> SessionRecord — bumps each URL's origin to the top in order; a URL with no origin (or None) records nothing; drops entries older than ttl except history[0]; caps at HISTORY_CAP.
    • SessionManager.touch(name: str, *urls: str | None, browser: str | None = None) -> None — positional single-URL calls keep working (Task 5 makes it return dict | None).
    • SessionManager.remember(...) — same signature; the new record keeps the old one's history and records url.
  • Step 1: Write the failing tests — tests/test_history.py:

"""Where a session has been: the history on its record.

One entry per origin, newest first, and the current page is the top one. Only
a page with an origin enters it, and a URL withheld after a secret write
records nothing (§F1.24). Entries expire with the record's TTL, the top one
excepted."""

import pytest

from kubed.selenium_flow.session.store import (
    HISTORY_CAP,
    MemoryStore,
    RedisStore,
    SessionRecord,
)

from .conftest import NAMED, RecordingActions, manager
from .test_sessions import FakeRedis

pytestmark = pytest.mark.unit

DAY = 86400.0


def origins(record):
    return [v["origin"] for v in record.history]


def test_a_page_bumps_its_origin_to_the_top_with_its_url_and_time():
    record = SessionRecord().at("https://a.test/1", now=10.0).at("https://b.test/", now=20.0)
    record = record.at("https://a.test/2", now=30.0)
    assert record.history == [
        {"origin": "https://a.test", "url": "https://a.test/2", "at": 30.0},
        {"origin": "https://b.test", "url": "https://b.test/", "at": 20.0},
    ]


def test_the_current_url_is_the_top_of_the_history():
    assert SessionRecord().url == ""
    assert SessionRecord().at("https://a.test/x", now=1.0).url == "https://a.test/x"


def test_a_page_with_no_origin_or_a_withheld_url_records_nothing():
    record = SessionRecord().at("https://a.test/x", now=1.0)
    for nowhere in (None, "", "about:blank", "data:text/html,hi"):
        assert record.at(nowhere, now=2.0).history == record.history


def test_several_pages_are_recorded_in_order():
    record = SessionRecord().at("https://a.test/", "https://b.test/", "https://a.test/z", now=5.0)
    assert [v["url"] for v in record.history] == ["https://a.test/z", "https://b.test/"]


def test_an_entry_older_than_the_ttl_goes_but_the_top_one_stays():
    record = SessionRecord().at("https://old.test/", now=0.0).at("https://mid.test/", now=10.0)
    later = record.at(None, now=DAY + 20.0, ttl=DAY)
    assert origins(later) == ["https://mid.test"], "both expired; the top is where a reopen goes"


def test_at_most_a_hundred_entries_and_the_oldest_goes_first():
    record = SessionRecord()
    for i in range(HISTORY_CAP + 5):
        record = record.at(f"https://h{i}.test/", now=float(i))
    assert len(record.history) == HISTORY_CAP
    assert record.history[0]["origin"] == f"https://h{HISTORY_CAP + 4}.test"
    assert record.history[-1]["origin"] == "https://h5.test"


@pytest.mark.parametrize(
    "store", [MemoryStore(), RedisStore(FakeRedis(), prefix="p:")], ids=lambda s: s.kind
)
def test_both_stores_round_trip_the_history(store):
    record = SessionRecord(session_id="s").at("https://a.test/1", now=1.0).at("https://b.test/2", now=2.0)
    store.set("k", record)
    assert store.get("k").history == record.history
    assert store.get("k").url == "https://b.test/2"


def test_a_record_from_before_the_history_reads_as_having_been_nowhere():
    old = SessionRecord.from_json('{"session_id": "s", "url": "https://a.test/x"}')
    assert (old.session_id, old.history, old.url) == ("s", [], "")


def test_a_malformed_entry_is_dropped_not_fatal():
    raw = (
        '{"session_id": "s", "history": ['
        '{"origin": "https://a.test", "url": "https://a.test/", "at": 1.0}, '
        '{"origin": 3}, "x", '
        '{"origin": "https://b.test", "url": "https://b.test/", "at": "soon"}]}'
    )
    assert SessionRecord.from_json(raw).history == [
        {"origin": "https://a.test", "url": "https://a.test/", "at": 1.0}
    ]
    assert SessionRecord.from_json('{"session_id": "s", "history": {"a": 1}}').history == []


def test_touch_bumps_the_history_and_a_withheld_url_still_slides_the_ttl():
    clock = [1000.0]
    store = MemoryStore(ttl=60, clock=lambda: clock[0])
    sessions = manager(store=store)
    sessions.remember(NAMED, "abc", "https://a.test/")
    sessions.touch(NAMED, "https://b.test/")
    clock[0] += 50
    sessions.touch(NAMED, None)
    clock[0] += 50
    record = store.get(NAMED)
    assert record is not None, "the session expired while it was being used"
    assert origins(record) == ["https://b.test", "https://a.test"]


def test_touch_from_a_browser_the_record_no_longer_names_records_nothing():
    sessions = manager()
    sessions.remember(NAMED, "new", "https://a.test/")
    sessions.touch(NAMED, "https://elsewhere.test/", browser="old")
    assert sessions.store.get(NAMED).url == "https://a.test/"


def test_opening_another_browser_keeps_where_the_session_has_been():
    sessions = manager(RecordingActions())
    sessions.remember(NAMED, "one", "https://a.test/")
    sessions.remember(NAMED, "two", "https://b.test/", replacing="one")
    assert origins(sessions.store.get(NAMED)) == ["https://b.test", "https://a.test"]


def test_ending_a_browser_keeps_the_history():
    sessions = manager(RecordingActions())
    sessions.remember(NAMED, "one", "https://a.test/")
    sessions.end_browser(NAMED)
    assert sessions.store.get(NAMED).url == "https://a.test/"
  • Step 2: Run to verify it fails

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py Expected: FAIL — ImportError: cannot import name 'HISTORY_CAP' from 'kubed.selenium_flow.session.store'.

  • Step 3: Implement the record — kubed/selenium_flow/session/store.py.

Add the import after from .. import errors (line 38):

from ..core.site_data import origin_of

Add after UPDATE_RETRIES = 10 (line 64):

# How many origins a session's history keeps; the oldest goes first.
HISTORY_CAP = 100

Replace the SessionRecord docstring and fields (lines 92–114, from """One flow session through the site_data field) with:

    """One flow session: its context, and the browser it currently holds.

    ``history`` and ``settings`` are the point of storing a record rather than
    a bare id. They are what makes a browser replaceable: reopening and
    navigating back to the last known page — the top of the history — in the
    browser it was using makes a refresh invisible, and makes
    ``open_session`` with no arguments do the obvious thing after the browser
    has gone.

    ``session_id`` is empty when no browser is attached, which is an ordinary
    state rather than a broken one: the Grid reaped it, or an admin ended it.
    """

    session_id: str = ""
    opened_at: float = 0.0
    # What the session was opened with, so a reopen uses the same browser
    # rather than a default one.
    settings: dict = field(default_factory=dict)
    # Where the session has been: one {"origin", "url", "at"} per origin,
    # newest first. Written by `at`; the admin History tab reads it.
    history: list = field(default_factory=list)
    # Cookies and storage the session saved (core/site_data.py). Kept with the
    # record so it expires with it; never on this server's disk (with Redis,
    # as durable as Redis).
    site_data: dict = field(default_factory=dict)

    @property
    def url(self) -> str:
        """The page the session is on: the top of its history, or ""."""
        return self.history[0]["url"] if self.history else ""

In from_json, replace the return cls(...) (lines 149–155) with:

            return cls(
                session_id=str(data.get("session_id") or ""),
                opened_at=float(data.get("opened_at", 0.0)),
                settings=settings if isinstance(settings, dict) else {},
                history=_visits(data.get("history")),
                site_data=site_data if isinstance(site_data, dict) else {},
            )

Replace at (lines 160–162) with:

    def at(
        self,
        *urls: str | None,
        now: float | None = None,
        ttl: float = DEFAULT_TTL_SECONDS,
    ) -> SessionRecord:
        """The same record, having landed on ``urls`` in order.

        Each one with an origin moves that origin to the top with its URL and
        the time. One without — None for a URL withheld after a secret write
        (§F1.24), ``about:blank``, ``data:`` — records nothing. Entries older
        than ``ttl`` go, except the top one: it is where a reopen goes back to.
        """
        now = time.time() if now is None else now
        history = list(self.history)
        for url in urls:
            origin = origin_of(url or "")
            if origin:
                history = [
                    {"origin": origin, "url": url, "at": now},
                    *(v for v in history if v["origin"] != origin),
                ]
        kept = history[:1] + [v for v in history[1:] if v["at"] >= now - ttl]
        return replace(self, history=kept[:HISTORY_CAP])

Add after the class (before class SessionStore(Protocol):):

def _visits(raw) -> list[dict]:
    """The well-formed entries of a stored history. A record written before
    there was one has none: it reads as a session that has been nowhere."""
    if not isinstance(raw, list):
        return []
    return [
        {"origin": v["origin"], "url": v["url"], "at": float(v["at"])}
        for v in raw
        if isinstance(v, dict)
        and isinstance(v.get("origin"), str) and v["origin"]
        and isinstance(v.get("url"), str) and v["url"]
        and isinstance(v.get("at"), (int, float)) and not isinstance(v["at"], bool)
    ]
  • Step 4: Implement the session side — kubed/selenium_flow/session/sessions.py.

In remember, replace the record built in bound (lines 736–742) with:

            return SessionRecord(
                session_id=session_id,
                opened_at=time.time(),
                settings=dict(settings or {}),
                # Where the session has been survives a new browser.
                history=list(r.history) if r is not None else [],
                site_data={} if forget_site_data or r is None else dict(r.site_data),
            ).at(url, ttl=ttl)

and add ttl = self.store.ttl as the first line of remember's body, before loser: dict = {}.

Replace touch (lines 763–786) with:

    def touch(
        self, name: str, *urls: str | None, browser: str | None = None
    ) -> None:
        """Record where the browser landed, and slide the record's TTL.

        Called after an action so a later reopen goes back to the right page —
        the top of the history — and so a session in active use does not
        expire out of the store underneath the caller. A flow run passes every
        page it reached, in order (`flows/api.run_for`).

        **No URL still slides the TTL**, and records nothing. The two halves
        are separate facts: "the browser is somewhere I should not write down"
        is not "this session is idle". A bound write lands on `?q=<the
        password>` and its URL is deliberately withheld (§F1.24); withholding
        it used to stop the clock, so a flow that logs in every few minutes —
        the one thing secrets exist for — expired out of the store while it
        was being used.

        ``browser`` is the one that produced the result. Once the record names
        another, this page is not that browser's: the TTL still slides, the
        history is left alone (Copilot, #50).
        """
        ttl = self.store.ttl

        def at(r: SessionRecord) -> SessionRecord:
            return r if browser and r.session_id != browser else r.at(*urls, ttl=ttl)

        self.store.update(name, at)
  • Step 5: Rewrite the tests that built a record with url= — a one-line constructor moves its literal into .at(...); url="" is dropped:
python3 - <<'EOF'
import pathlib
import re

# `url` is no longer a field: the page is the top of the history, which `at`
# writes. A literal url on one line moves into `.at(...)`; an empty one goes.
PATTERN = re.compile(r'SessionRecord\(([^()\n]*?)\burl=(f?"[^"]*")(?:, )?([^()\n]*)\)')


def fix(match):
    args = (match[1] + match[3]).strip().removesuffix(",").strip()
    call = f"SessionRecord({args})"
    return call if match[2] == '""' else f"{call}.at({match[2]})"


for path in pathlib.Path("tests").glob("test_*.py"):
    text = path.read_text()
    fixed = PATTERN.sub(fix, text).replace(
        'replace(r, url="https://b.test/")', 'r.at("https://b.test/")'
    )
    if fixed != text:
        path.write_text(fixed)
        print("rewrote", path)
EOF

Expected: eight files rewritten (test_admin_files, test_files_and_admin, test_kept_files, test_secrets, test_sessions, test_site_data, test_site_data_sessions, test_site_data_surface).

Then the two multi-line constructions, by hand. In tests/test_sessions.py (the fresh test, lines 1204–1208):

        SessionRecord(
            session_id="",
            url="https://app.test/orders",
            settings={"browser": "firefox", "width": 1400, "height": 900},
        ),

becomes

        SessionRecord(
            session_id="",
            settings={"browser": "firefox", "width": 1400, "height": 900},
        ).at("https://app.test/orders"),

and in tests/bench/test_session_store.py (lines 40–45):

            SessionRecord(
                session_id=f"{index:032x}" if index % 4 else "",
                url=f"https://app.example.com/section/{index}?tab=overview",
                opened_at=1_760_000_000.0 + index,
                settings={"browser": "chrome", "width": 1440, "height": 900},
            ),

becomes

            SessionRecord(
                session_id=f"{index:032x}" if index % 4 else "",
                opened_at=1_760_000_000.0 + index,
                settings={"browser": "chrome", "width": 1440, "height": 900},
            ).at(f"https://app.example.com/section/{index}?tab=overview"),

Confirm nothing is left: grep -rn 'SessionRecord(' tests | grep 'url=' prints nothing, and grep -rn -B3 '^\s*url=' tests/*.py tests/bench/*.py | grep SessionRecord prints nothing.

  • Step 6: Run to verify it passes

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py → PASS. Then the whole unit suite (Global Constraints) → PASS, and $S/tools/bin/ruff check kubed tests scripts → clean.

  • Step 7: Break it on purpose — in SessionRecord.at, change kept = history[:1] + [...] to kept = [v for v in history if v["at"] >= now - ttl]; confirm test_an_entry_older_than_the_ttl_goes_but_the_top_one_stays fails; restore.

  • Step 8: Commit

git add kubed/selenium_flow/session/store.py kubed/selenium_flow/session/sessions.py \
  tests/test_history.py tests/test_admin_files.py tests/test_kept_files.py \
  tests/test_files_and_admin.py tests/test_site_data_surface.py tests/test_secrets.py \
  tests/test_site_data.py tests/test_sessions.py tests/test_site_data_sessions.py \
  tests/bench/test_session_store.py
git commit -m "Site data round 2: the history on the record; url is its top entry" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: A flow run writes every page it reached, in one update

Files:

  • Modify: kubed/selenium_flow/flows/api.py (run_for, the touch at 574–589)
  • Test: tests/test_history.py (append)

Interfaces:

  • Consumes: Task 1's SessionManager.touch(name, *urls, browser=None).

  • Produces: flows.api.run_for(...) — unchanged signature and return; its one touch now carries, in order, every step's url from the report (only steps with ok true — a failed step's URL is the scrubbed one), then the run's own url unless url_redacted.

  • Step 1: Write the failing tests — append to tests/test_history.py:

# ---- a flow run writes every page it reached, in one update ------------------


def run_reporting(monkeypatch, report):
    """`run_for` around a canned run report: what reaches the history is
    decided from the report alone, in one store write."""
    from kubed.selenium_flow.flows import api as flowapi

    monkeypatch.setattr(flowapi, "run_one", lambda *a, **kw: report)
    sessions = manager(RecordingActions())
    sessions.open_browser(NAMED, url="https://start.test/")
    writes = []
    real = sessions.store.update

    def counting(key, fn):
        writes.append(key)
        return real(key, fn)

    monkeypatch.setattr(sessions.store, "update", counting)
    flowapi.run_for(None, sessions.actions, sessions, NAMED, "login")
    return sessions, writes


def test_a_flow_run_records_each_steps_page_in_order_in_one_write(monkeypatch):
    sessions, writes = run_reporting(monkeypatch, {
        "status": "ok",
        "steps": [
            {"n": 1, "ok": True, "url": "https://app.test/login"},
            {"n": 2, "ok": True, "url": "https://sso.test/auth"},
            {"n": 3, "ok": True},
            {"n": 4, "ok": True, "url": "https://app.test/home"},
        ],
        "url": "https://app.test/home",
    })
    assert [v["url"] for v in sessions.store.get(NAMED).history] == [
        "https://app.test/home", "https://sso.test/auth", "https://start.test/",
    ]
    assert writes == [NAMED], "one update for the whole run"


def test_a_failed_step_and_a_redacted_end_record_nothing(monkeypatch):
    sessions, _ = run_reporting(monkeypatch, {
        "status": "failed",
        "steps": [
            {"n": 1, "ok": True, "url": "https://app.test/login"},
            {"n": 2, "ok": False, "url": "https://other.test/?q=[hidden]"},
        ],
        "url": "https://other.test/?q=[hidden]",
        "url_redacted": True,
    })
    assert [v["url"] for v in sessions.store.get(NAMED).history] == [
        "https://app.test/login", "https://start.test/",
    ]


def test_a_run_that_went_nowhere_still_slides_the_ttl(monkeypatch):
    sessions, writes = run_reporting(monkeypatch, {"status": "ok", "steps": [{"n": 1, "ok": True}]})
    assert writes == [NAMED]
    assert sessions.store.get(NAMED).url == "https://start.test/"
  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py Expected: FAIL — the first test's history is ["https://app.test/home", "https://start.test/"] (only the run's last page today), and the second records https://app.test/login nowhere.

  • Step 3: Implement — in kubed/selenium_flow/flows/api.py, replace the comment and touch at the end of run_for (lines 574–589, from # One touch for the whole run through return report) with:
    # One touch for the whole run, not one per step: the point of running
    # server-side is that the bookkeeping happens once. It carries every page
    # the run reached, in order, so the history has each step's site and not
    # only the last.
    #
    # Only pages the report itself shows. A step's `url` is there only when
    # the step moved and the page was safe to show, so a failed step's — the
    # scrubbed one, a URL that does not exist — is left out. And the run's own
    # page is withheld when redaction had to touch it: a submitting bound
    # write lands on `?q=<what was typed>`, and `sessions.resolve` would
    # reopen the browser there after the Grid reaped it.
    #
    # The touch happens regardless: it slides the TTL, and a run is the
    # clearest evidence there is that a session is in use.
    visited = [
        step["url"] for step in report.get("steps") or []
        if step.get("ok") and step.get("url")
    ]
    if report.get("url") and not report.get("url_redacted"):
        visited.append(report["url"])
    sessions.touch(session, *visited, browser=resolved)
    return report
  • Step 4: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py tests/test_flowapi.py tests/test_flowrun.py tests/test_site_data_sessions.py → PASS; ruff clean.

  • Step 5: Break it on purpose — drop step.get("ok") and from the comprehension; confirm test_a_failed_step_and_a_redacted_end_record_nothing fails; restore.

  • Step 6: Commit

git add kubed/selenium_flow/flows/api.py tests/test_history.py
git commit -m "Site data round 2: a flow run writes every page it reached, in one update" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: The spare tab

Files:

  • Modify: kubed/selenium_flow/core/browser.py (constants after BIDI_TIMEOUT, line 54; Grid.bidi 291–314; new code after class Grid, before def _waited)
  • Create: tests/test_spare_tab.py

Interfaces:

  • Produces (Tasks 4 and 8 use them through site_data):

    • browser.BIDI_INTERVAL = 0.005 — set on every Grid.bidi driver as command_executor.client_config.websocket_interval (Selenium 4.49's own knob; the spike set exactly this).
    • browser.SPARE_MARKER = "selenium-flow-spare", browser.SPARE_PAGE = '<!doctype html><meta name="selenium-flow-spare">'
    • class browser.ServiceWorkerAnswered(RuntimeError) — str(exc) is the origin.
    • browser.spare_tab(bidi, context: str | None = None) — a context manager yielding run(origin: str, expression: str) -> object. run navigates the tab to origin + "/" (wait="complete"), evaluates expression there only if the page carries the marker and stands on origin, and returns its JSON value. Raises ServiceWorkerAnswered (no marker), RuntimeError (the tab is elsewhere, or the script threw), or whatever BiDi raises. With context it intercepts that existing tab (the main one) and leaves it open; without, it opens a background tab and closes it. Teardown always runs and never raises.
  • The Selenium calls, as the spike proved them (Chrome 152, Firefox 155): browsing_context.create(type="tab", background=True); network.add_intercept(phases=["beforeRequestSent"], contexts=[tab])["intercept"]; network.add_event_handler("before_request", fn, contexts=[tab]) — not before_request_sent, which hands fn an event without the request; fn gets a plain dict on its own thread; network.provide_response(request=, status_code=200, headers=[…], body={"type": "string", "value": …}); browsing_context.navigate(context=, url=, wait="complete"); script.evaluate(expression=, target={"context": tab}, await_promise=True) returns {"type": "success", "result": {"type": …, "value": …}} or {"type": "exception", "exceptionDetails": {"text": …}}; network.remove_event_handler("before_request", id) — Selenium subscribes once per event with the first handler's contexts and unsubscribes when the last handler goes, so removing it is what lets the next tab subscribe afresh; network.remove_intercept(intercept=); browsing_context.close(context=). Firefox also requests /favicon.ico; it gets the same page, harmlessly.

  • Step 1: Write the failing tests — tests/test_spare_tab.py:

"""The spare tab: a tab whose every request is answered with a marked blank
page, so it can stand on any origin without the site loading (spec round 2,
*The spike*). BiDi is faked module by module; what `spare_tab` sends, and in
which order, is the contract the live spike proved."""

import json
import re

import pytest

from kubed.selenium_flow.core.browser import (
    BIDI_INTERVAL,
    SPARE_MARKER,
    SPARE_PAGE,
    Grid,
    ServiceWorkerAnswered,
    spare_tab,
)

pytestmark = pytest.mark.unit


class FakeTabs:
    def __init__(self, log):
        self.log, self.at = log, {}

    def create(self, type=None, background=None, **_):
        self.log.append(("create", type, background))
        return "spare-1"

    def navigate(self, context=None, url=None, wait=None):
        self.log.append(("navigate", context, url, wait))
        self.at[context] = url.rstrip("/")

    def close(self, context=None):
        self.log.append(("close", context))


class FakeNetwork:
    def __init__(self, log):
        self.log, self.handlers, self.responses = log, {}, []

    def add_intercept(self, phases=None, contexts=None, **_):
        self.log.append(("intercept", phases, contexts))
        return {"intercept": "i-1"}

    def remove_intercept(self, intercept=None):
        self.log.append(("unintercept", intercept))

    def add_event_handler(self, event, callback, contexts=None):
        self.log.append(("handler", event, contexts))
        self.handlers[7] = callback
        return 7

    def remove_event_handler(self, event, callback_id):
        self.log.append(("unhandler", event, callback_id))

    def provide_response(self, **kw):
        self.responses.append(kw)


class FakeScript:
    """Answers as the page would: ours, with the marker, or a service
    worker's. ``stuck`` leaves the tab on the page it was on before."""

    def __init__(self, log, tabs, value=None, workers=(), stuck=False, fail=None):
        self.log, self.tabs = log, tabs
        self.value, self.workers, self.stuck, self.fail = value, set(workers), stuck, fail
        self.expression = None

    def evaluate(self, expression=None, target=None, await_promise=None):
        self.log.append(("evaluate", target, await_promise))
        self.expression = expression
        if self.fail:
            return {"type": "exception", "exceptionDetails": {"text": self.fail}}
        origin = "https://before.test" if self.stuck else self.tabs.at[target["context"]]
        page = {"spare": origin not in self.workers, "origin": origin}
        if page["spare"]:
            page["value"] = self.value
        return {"type": "success", "result": {"type": "string", "value": json.dumps(page)}}


class FakeBidi:
    def __init__(self, **kw):
        self.log = []
        self.browsing_context = FakeTabs(self.log)
        self.network = FakeNetwork(self.log)
        self.script = FakeScript(self.log, self.browsing_context, **kw)


def test_a_spare_tab_stands_on_an_origin_runs_there_and_closes():
    bidi = FakeBidi(value={"theme": "dark"})
    with spare_tab(bidi) as run:
        assert run("https://app.test", "localStorage.length") == {"theme": "dark"}
    assert bidi.log == [
        ("create", "tab", True),
        ("intercept", ["beforeRequestSent"], ["spare-1"]),
        ("handler", "before_request", ["spare-1"]),
        ("navigate", "spare-1", "https://app.test/", "complete"),
        ("evaluate", {"context": "spare-1"}, True),
        ("unhandler", "before_request", 7),
        ("unintercept", "i-1"),
        ("close", "spare-1"),
    ]
    assert SPARE_MARKER in bidi.script.expression
    assert "localStorage.length" in bidi.script.expression


def test_every_blocked_request_is_answered_with_the_marked_page():
    bidi = FakeBidi()
    with spare_tab(bidi):
        answer = bidi.network.handlers[7]
        answer({"isBlocked": True, "request": {"request": "r-1", "url": "https://app.test/"}})
        answer({"isBlocked": False, "request": {"request": "r-2", "url": "https://app.test/x"}})
        answer("not an event")
    assert bidi.network.responses == [{
        "request": "r-1",
        "status_code": 200,
        "headers": [{"name": "content-type", "value": {"type": "string", "value": "text/html"}}],
        "body": {"type": "string", "value": SPARE_PAGE},
    }]
    assert SPARE_MARKER in SPARE_PAGE


def test_a_refused_response_never_raises_on_the_event_thread():
    bidi = FakeBidi()

    def refuse(**kw):
        raise RuntimeError("no such request")

    bidi.network.provide_response = refuse
    with spare_tab(bidi):
        bidi.network.handlers[7]({"isBlocked": True, "request": {"request": "r-1"}})


def test_a_service_worker_page_is_refused_before_anything_runs_there():
    bidi = FakeBidi(value="read", workers={"https://sw.test"})
    with pytest.raises(ServiceWorkerAnswered, match=re.escape("https://sw.test")), spare_tab(bidi) as run:
        run("https://sw.test", "localStorage.length")
    assert bidi.log[-1] == ("close", "spare-1"), "the tab still closes"


def test_a_tab_that_did_not_move_is_never_read_as_the_new_origin():
    bidi = FakeBidi(value="stale", stuck=True)
    with pytest.raises(RuntimeError, match=re.escape("not https://app.test")), spare_tab(bidi) as run:
        run("https://app.test", "1")


def test_a_script_that_throws_raises_its_text_and_the_tab_still_closes():
    bidi = FakeBidi(fail="boom")
    with pytest.raises(RuntimeError, match="boom"), spare_tab(bidi) as run:
        run("https://app.test", "1")
    assert bidi.log[-1] == ("close", "spare-1")


def test_an_existing_tab_is_intercepted_and_left_open():
    bidi = FakeBidi(value=1)
    with spare_tab(bidi, context="main-1") as run:
        run("https://app.test", "1")
    kinds = [entry[0] for entry in bidi.log]
    assert "create" not in kinds and "close" not in kinds
    assert ("intercept", ["beforeRequestSent"], ["main-1"]) in bidi.log


def test_the_bidi_socket_is_polled_every_few_milliseconds():
    """Selenium's 100 ms default put a 100 ms floor under every BiDi call
    (spike, 2026-10-01)."""
    with Grid("http://grid.example:4444").bidi("abc") as driver:
        assert driver.command_executor.client_config.websocket_interval == BIDI_INTERVAL
    assert BIDI_INTERVAL <= 0.01
  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_spare_tab.py Expected: FAIL — ImportError: cannot import name 'BIDI_INTERVAL'.

  • Step 3: Implement — kubed/selenium_flow/core/browser.py.

After BIDI_TIMEOUT = 5.0 (line 54) add:

# How often Selenium looks for a BiDi reply. Its 100 ms default is a floor
# under every BiDi command; at 5 ms a spare tab costs 30-140 ms per origin.
BIDI_INTERVAL = 0.005
# What a spare tab is answered with. The marker tells it from a site's own
# page, which a service worker serves before any intercept sees the request.
SPARE_MARKER = "selenium-flow-spare"
SPARE_PAGE = f'<!doctype html><meta name="{SPARE_MARKER}">'

In Grid.bidi, replace the docstring and the timeout lines (292–305) so the method reads:

    @contextlib.contextmanager
    def bidi(self, session_id: str):
        """A reattached driver that speaks BiDi: ``.storage``, ``.network``,
        ``.browsing_context``, ``.script``.

        The socket address is derived, not discovered: the Grid proxies it at
        ``/session/<id>/se/bidi``.
        """
        parts = urlsplit(self.url)
        scheme = "wss" if parts.scheme == "https" else "ws"
        socket = urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
        driver = self.reconnect(session_id)
        driver.caps = {"webSocketUrl": f"{socket}/session/{session_id}/se/bidi"}
        # Selenium waits 30 s for a socket that never answers; an open would
        # stall that long on a Grid whose BiDi route is down.
        driver.command_executor.client_config.websocket_timeout = BIDI_TIMEOUT
        driver.command_executor.client_config.websocket_interval = BIDI_INTERVAL
        try:
            yield driver
        finally:
            # Selenium opens the socket lazily on first use; closing it is the
            # only cleanup, the browser is not ours to quit.
            connection = getattr(driver, "_websocket_connection", None)
            if connection is not None:
                with contextlib.suppress(Exception):
                    connection.close()

After the end of class Grid (before def _waited) add:

class ServiceWorkerAnswered(RuntimeError):
    """A spare tab got the site's own page: its service worker answered before
    the intercept could. Nothing is read or written there."""


@contextlib.contextmanager
def spare_tab(bidi, context: str | None = None):
    """A tab whose every request is answered with :data:`SPARE_PAGE`, so it
    can stand on any origin without that site loading — Playwright's way to
    reach another origin's storage.

    Yields ``run(origin, expression)``: stand on ``origin`` and return what
    ``expression`` evaluates to there, JSON-safe. A page without the marker is
    a service worker's (:class:`ServiceWorkerAnswered`) and is never touched.

    With ``context``, that existing tab is intercepted instead and left open:
    the main tab, for its sessionStorage. Selenium subscribes once per event,
    with the first handler's contexts, so the handler is removed on the way
    out and the next tab subscribes afresh.
    """
    tab = context or bidi.browsing_context.create(type="tab", background=True)
    intercept = handler = None
    try:
        intercept = bidi.network.add_intercept(
            phases=["beforeRequestSent"], contexts=[tab]
        )["intercept"]

        def answer(event):
            # On Selenium's own thread, one per event: a refusal shows up as a
            # page without the marker, not as an error here.
            if not isinstance(event, dict) or not event.get("isBlocked"):
                return
            with contextlib.suppress(Exception):
                bidi.network.provide_response(
                    request=event["request"]["request"],
                    status_code=200,
                    headers=[{
                        "name": "content-type",
                        "value": {"type": "string", "value": "text/html"},
                    }],
                    body={"type": "string", "value": SPARE_PAGE},
                )

        # `before_request`, not `before_request_sent`: the other name hands
        # the callback an event without the request's fields.
        handler = bidi.network.add_event_handler(
            "before_request", answer, contexts=[tab]
        )

        def run(origin: str, expression: str):
            bidi.browsing_context.navigate(
                context=tab, url=origin + "/", wait="complete"
            )
            reply = bidi.script.evaluate(
                expression=_on_spare(expression),
                target={"context": tab},
                await_promise=True,
            )
            if reply.get("type") != "success":
                details = reply.get("exceptionDetails") or {}
                raise RuntimeError(details.get("text") or "the script failed")
            got = json.loads(reply["result"]["value"])
            if not got["spare"]:
                raise ServiceWorkerAnswered(origin)
            if got["origin"] != origin:
                # A navigation that did not land must never be read as this
                # origin: the tab still holds the last one's page.
                raise RuntimeError(f"the spare tab is on {got['origin']}, not {origin}")
            return got["value"]

        yield run
    finally:
        if handler is not None:
            with contextlib.suppress(Exception):
                bidi.network.remove_event_handler("before_request", handler)
        if intercept is not None:
            with contextlib.suppress(Exception):
                bidi.network.remove_intercept(intercept=intercept)
        if context is None:
            with contextlib.suppress(Exception):
                bidi.browsing_context.close(context=tab)


def _on_spare(expression: str) -> str:
    """``expression``, run only on our page, as a JSON string that also says
    whose page it was and where."""
    return (
        "JSON.stringify(document.querySelector('meta[name=\"" + SPARE_MARKER + "\"]')"
        " ? {spare: true, origin: location.origin, value: (" + expression + ")}"
        " : {spare: false, origin: location.origin})"
    )
  • Step 4: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_spare_tab.py tests/test_site_data.py tests/test_browser_choice.py → PASS; ruff clean.

  • Step 5: Break it on purpose — change "before_request" in add_event_handler to "before_request_sent"; confirm test_a_spare_tab_stands_on_an_origin_runs_there_and_closes fails; restore.

  • Step 6: Commit

git add kubed/selenium_flow/core/browser.py tests/test_spare_tab.py
git commit -m "Site data round 2: the spare tab, and BiDi polled every 5 ms" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: The direct restore, and the preload script goes

Files:

  • Modify: kubed/selenium_flow/core/site_data.py — module docstring (line 11–13), MARKER (31–33), merge's session filter (99–105), delete preload_source (136–152), _add_preload (427–429), restore (492–552), arrive (555–572), retire (575–579), replace (582–604); add SW_REASON, READ_LOCAL, fill, restorable, _why, _restore_cookies, the new restore.
  • Modify: kubed/selenium_flow/core/actions.py — open_session (364–466); delete retire_site_data (479–492).
  • Modify: kubed/selenium_flow/spec/schemas.py — SITE_DATA_HINT (24–67).
  • Create: tests/site_data_fakes.py, tests/test_site_data_restore.py
  • Modify: tests/test_site_data.py — delete the restore, preload and Actions-open tests (Step 5).

Interfaces:

  • Consumes: Task 3's browser.spare_tab, browser.ServiceWorkerAnswered.

  • Produces:

    • The snapshot shape every later task reads and Task 8 writes: {"cookies": [cookie], "origins": {origin: {"local": {k: v}}}, "session": {"origin": str, "items": {k: v}}, "saved_at": float}. A cookie dict is round 1's: name, value, value_type, domain, path, http_only, secure, same_site, expiry.
    • site_data.SW_REASON = "a service worker answered: save while on this site"
    • site_data.READ_LOCAL: str — an expression evaluating to the origin's localStorage as an object.
    • site_data.fill(store: str, items: dict) -> str — store is "localStorage" or "sessionStorage"; the expression sets every item and evaluates to how many.
    • site_data.restorable(data: dict) -> bool — cookies, any origin, or session items.
    • site_data.restore(bidi, data: dict, now: float) -> dict → {"restored": [host, …] (sorted), "skipped": [item, …], "uri": "session://site-data"}. Never raises. A skip item is {"cookie", "domain", "reason"} (a cookie), {"site", "reason"} (an origin's storage) or {"reason"} (the whole channel). bidi must offer .storage, .browsing_context, .current_window_handle (the main tab's context id, which equals the window handle), and is what spare_tab drives.
    • Actions.open_session(..., site_data=None) — restores before driver.get(url); the result carries "site_data": report only when report["restored"] or report["skipped"]; never _site_data_pending.
    • Deleted: MARKER, preload_source, arrive, retire, replace, _add_preload, Actions.retire_site_data.
  • Note: until Task 5 lands, sessions.py still has its pending-note code; it receives no _site_data_pending any more, stores no note, and so a silent reopen's report is not announced. Task 5 restores that, without the note.

  • Step 1: Write the shared fakes — tests/site_data_fakes.py (not collected: no test_ prefix):

"""Fakes for the site data tests: a cookie jar, the driver `Grid.bidi`
yields, and the spare tab. Shared by the restore and the save tests, so the
two fake one browser."""

import json
from contextlib import contextmanager
from types import SimpleNamespace

from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.core.browser import ServiceWorkerAnswered

NOW = 1_790_800_000.0
MAIN = "main-1"


def cookie(name, domain, value="v", http_only=False, expiry=None, secure=True):
    """A cookie as the snapshot stores it."""
    return {
        "name": name, "value": value, "value_type": "string", "domain": domain,
        "path": "/", "http_only": http_only, "secure": secure,
        "same_site": "lax", "expiry": expiry,
    }


def snapshot(cookies=(), origins=None, session=None, saved_at=NOW):
    """A saved snapshot in round 2's shape; ``origins`` maps an origin to its
    localStorage."""
    return {
        "cookies": list(cookies),
        "origins": {o: {"local": dict(v)} for o, v in (origins or {}).items()},
        "session": dict(session or {}),
        "saved_at": saved_at,
    }


def bidi_cookie(name, domain, value="v", http_only=False):
    """A cookie as BiDi's storage.getCookies returns it."""
    return SimpleNamespace(
        name=name, domain=domain, path="/", http_only=http_only, secure=True,
        same_site="lax", expiry=None, value=SimpleNamespace(type="string", value=value),
    )


class FakeStorage:
    """A jar: ``cookies`` are there from the start; what is set reads back,
    except a ``drop``ped name — accepted without an error and never kept, as
    Chrome does to SameSite=None without Secure."""

    def __init__(self, cookies=(), refuse=(), drop=(), order=None):
        self.cookies, self.set = list(cookies), []
        self.refuse, self.drop, self.order = set(refuse), set(drop), order

    def get_cookies(self, filter=None, partition=None):
        held = [
            SimpleNamespace(name=c.name, domain=c.domain, path=c.path)
            for c in self.set
            if c.name not in self.drop and not (c.same_site == "none" and not c.secure)
        ]
        return SimpleNamespace(cookies=self.cookies + held)

    def set_cookie(self, cookie=None, partition=None):
        if cookie.name in self.refuse:
            raise RuntimeError("unable to set cookie")
        self.set.append(cookie)
        if self.order is not None:
            self.order.append("cookie")


class FakeTabs:
    def __init__(self):
        self.navigated = []

    def navigate(self, context=None, url=None, wait=None):
        self.navigated.append((context, url))


class FakeBidi:
    """What `Grid.bidi` yields: the jar, the main tab's id, navigation."""

    current_window_handle = MAIN

    def __init__(self, order=None, **kw):
        self.storage = FakeStorage(order=order, **kw)
        self.browsing_context = FakeTabs()


def bidi_cm(fake):
    """A stand-in for `Grid.bidi` that yields ``fake``."""

    @contextmanager
    def cm(session_id):
        yield fake

    return cm


class FakeSpare:
    """`browser.spare_tab`, faked over two dicts of storage by origin.

    An origin in ``refuse`` fails to load, one in ``workers`` is answered by a
    service worker, and ``broken`` makes the tab itself fail to open. Each run
    is logged as (tab, origin); ``order``, when given, collects the tab too.
    """

    def __init__(self, local=None, refuse=(), workers=(), broken=None, order=None):
        self.local = {o: dict(v) for o, v in (local or {}).items()}
        self.session: dict = {}
        self.refuse, self.workers = set(refuse), set(workers)
        self.broken, self.order = broken, order
        self.runs: list = []
        self.tabs: list = []

    @contextmanager
    def __call__(self, bidi, context=None):
        if self.broken:
            raise self.broken
        tab = context or "spare"
        self.tabs.append(tab)

        def run(origin, expression):
            self.runs.append((tab, origin))
            if self.order is not None:
                self.order.append(tab)
            if origin in self.workers:
                raise ServiceWorkerAnswered(origin)
            if origin in self.refuse:
                raise RuntimeError("the navigation failed")
            if expression == sd.READ_LOCAL:
                return dict(self.local.get(origin, {}))
            store, items = filled(expression)
            target = self.session if store == "sessionStorage" else self.local
            target.setdefault(origin, {}).update(items)
            return len(items)

        yield run


def filled(expression):
    """What a `site_data.fill` expression sets, and in which store: it carries
    its items as JSON."""
    store = "sessionStorage" if "sessionStorage.setItem" in expression else "localStorage"
    items = json.loads(expression.split("const items = ", 1)[1].split("; for ", 1)[0])
    return store, items
  • Step 2: Write the failing tests — tests/test_site_data_restore.py:
"""A restore, straight into the browser before its first page: cookies, then
each origin's localStorage in a spare tab, then the saved sessionStorage in
the main tab (spec round 2, *open_session*). The spare tab is faked at
`site_data.spare_tab`; `browser.spare_tab` has its own tests."""

import json
import shutil
import subprocess

import pytest

from kubed.selenium_flow.core import site_data as sd

from .site_data_fakes import (
    MAIN,
    NOW,
    FakeBidi,
    FakeSpare,
    FakeStorage,
    bidi_cm,
    cookie,
    snapshot,
)

pytestmark = pytest.mark.unit

LIST = "session://site-data"
APP = "https://app.example.com"


@pytest.fixture
def spare(monkeypatch):
    fake = FakeSpare()
    monkeypatch.setattr(sd, "spare_tab", fake)
    return fake


def test_cookies_then_each_origins_storage_then_session_storage(spare):
    order = []
    spare.order = order
    bidi = FakeBidi(order=order)
    data = snapshot(
        cookies=[cookie("sid", "app.example.com"), cookie("kc", "sso.example.com"),
                 cookie("old", "app.example.com", expiry=int(NOW) - 1)],
        origins={APP: {"theme": "dark"}, "https://sso.example.com": {"kc": "1"}},
        session={"origin": APP, "items": {"token": "t"}},
    )
    report = sd.restore(bidi, data, NOW)
    assert [c.name for c in bidi.storage.set] == ["sid", "kc"], "the expired one stays out"
    assert spare.local == {APP: {"theme": "dark"}, "https://sso.example.com": {"kc": "1"}}
    assert spare.session == {APP: {"token": "t"}}
    assert order == ["cookie", "cookie", "spare", "spare", MAIN]
    assert spare.tabs == ["spare", MAIN], "one spare tab for every origin, then the main tab"
    assert bidi.browsing_context.navigated == [(MAIN, "about:blank")], "never left on the stand-in"
    assert report == {"restored": ["app.example.com", "sso.example.com"], "skipped": [], "uri": LIST}


def test_a_cookie_only_snapshot_opens_no_tab(spare):
    report = sd.restore(FakeBidi(), snapshot(cookies=[cookie("sid", "app.example.com")]), NOW)
    assert spare.tabs == []
    assert report == {"restored": ["app.example.com"], "skipped": [], "uri": LIST}


def test_an_origin_that_fails_is_skipped_and_the_rest_go_on(spare):
    spare.refuse = {"https://bad.example.com"}
    data = snapshot(origins={"https://bad.example.com": {"a": "1"}, "https://ok.example.com": {"b": "2"}})
    report = sd.restore(FakeBidi(), data, NOW)
    assert spare.local == {"https://ok.example.com": {"b": "2"}}
    assert report["restored"] == ["ok.example.com"]
    assert report["skipped"] == [{"site": "https://bad.example.com", "reason": "the navigation failed"}]


def test_session_storage_that_fails_is_skipped_not_fatal(spare):
    spare.refuse = {APP}
    data = snapshot(session={"origin": APP, "items": {"t": "1"}})
    assert sd.restore(FakeBidi(), data, NOW) == {
        "restored": [], "uri": LIST,
        "skipped": [{"site": APP, "reason": "the navigation failed"}],
    }


def test_a_tab_that_cannot_open_skips_its_origins_and_the_cookies_stand(spare):
    spare.broken = RuntimeError("socket is already closed")
    data = snapshot(
        cookies=[cookie("sid", "app.example.com")],
        origins={APP: {"k": "v"}},
        session={"origin": "https://sso.example.com", "items": {"t": "1"}},
    )
    report = sd.restore(FakeBidi(), data, NOW)
    assert report["restored"] == ["app.example.com"], "the cookie came back"
    assert report["skipped"] == [
        {"site": APP, "reason": "socket is already closed"},
        {"site": "https://sso.example.com", "reason": "socket is already closed"},
    ]


def test_a_dead_channel_is_reported_never_raised(spare):
    spare.broken = RuntimeError("socket is already closed")

    class Dead:
        current_window_handle = MAIN

        @property
        def storage(self):
            raise RuntimeError("socket is already closed")

    data = snapshot(cookies=[cookie("sid", "a.example.com")], origins={"https://a.example.com": {"k": "v"}})
    report = sd.restore(Dead(), data, NOW)
    assert report["restored"] == []
    assert {s["reason"] for s in report["skipped"]} == {"socket is already closed"}


def test_a_none_same_site_without_secure_is_restored_as_lax(spare):
    """Chrome reports an unspecified SameSite as "none" and silently refuses
    None without Secure: the reopened session came back signed out."""
    loose = {**cookie("sid", "app.example.com", secure=False), "same_site": "none"}
    tight = {**cookie("tok", "app.example.com", secure=True), "same_site": "none"}
    fox = {**cookie("fx", "app.example.com", secure=False), "same_site": "default"}
    bidi = FakeBidi()
    report = sd.restore(bidi, snapshot(cookies=[loose, tight, fox]), NOW)
    assert {c.name: c.same_site for c in bidi.storage.set} == {"sid": "lax", "tok": "none", "fx": "default"}
    assert report["skipped"] == [] and report["restored"] == ["app.example.com"]


def test_a_cookie_refused_or_not_kept_is_skipped(spare):
    data = snapshot(cookies=[cookie("gone", "only.example.com"), cookie("ok", "app.example.com"),
                             cookie("bad", "refused.example.com")])
    report = sd.restore(FakeBidi(drop={"gone"}, refuse={"bad"}), data, NOW)
    assert report["skipped"] == [
        {"cookie": "bad", "domain": "refused.example.com", "reason": "unable to set cookie"},
        {"cookie": "gone", "domain": "only.example.com", "reason": "the browser did not keep it"},
    ]
    assert report["restored"] == ["app.example.com"], "a host with nothing kept is not restored"


def test_an_unreadable_jar_trusts_the_set(spare):
    class Blind(FakeStorage):
        def get_cookies(self, filter=None, partition=None):
            raise RuntimeError("no read")

    bidi = FakeBidi()
    bidi.storage = Blind()
    report = sd.restore(bidi, snapshot(cookies=[cookie("ok", "app.example.com")]), NOW)
    assert report["restored"] == ["app.example.com"] and report["skipped"] == []


def test_one_malformed_cookie_is_skipped_not_the_whole_restore(spare):
    good = cookie("ok", "app.example.com")
    nameless = {k: v for k, v in cookie("x", "app.example.com").items() if k != "name"}
    domainless = {**cookie("nodomain", "app.example.com"), "domain": None}
    stringy = cookie("odd", "app.example.com", expiry="tomorrow")
    bidi = FakeBidi()
    report = sd.restore(bidi, snapshot(cookies=[nameless, domainless, stringy, good]), NOW)
    assert [c.name for c in bidi.storage.set] == ["ok"]
    # Only string fields ride along: the published shape declares them strings.
    assert report["skipped"] == [
        {"reason": "a stored cookie with no name or domain", "domain": "app.example.com"},
        {"reason": "a stored cookie with no name or domain", "cookie": "nodomain"},
        {"reason": "a stored cookie whose expiry is not a time", "cookie": "odd",
         "domain": "app.example.com"},
    ]


def test_a_failure_never_leaks_the_grid_credential(spare):
    leak = "http://user:pass@grid:4444"
    spare.broken = RuntimeError(f"connect to {leak}/session failed")
    bidi = FakeBidi()

    def refuse(cookie=None, partition=None):
        raise RuntimeError(f"refused by {leak}")

    bidi.storage.set_cookie = refuse
    data = snapshot(cookies=[cookie("sid", "a.example.com")], origins={"https://a.example.com": {"k": "v"}})
    report = sd.restore(bidi, data, NOW)
    assert len(report["skipped"]) == 2
    assert "user:pass" not in json.dumps(report)


def test_every_skipped_item_restore_emits_is_declared(spare):
    from kubed.selenium_flow.spec.schemas import SITE_DATA_HINT

    declared = SITE_DATA_HINT["properties"]["skipped"]["items"]
    spare.refuse = {"https://bad.example.com"}
    data = snapshot(
        cookies=[cookie("gone", "a.example.com"), cookie("bad", "b.example.com"), {"name": "x"}],
        origins={"https://bad.example.com": {"k": "v"}},
    )
    report = sd.restore(FakeBidi(drop={"gone"}, refuse={"bad"}), data, NOW)
    assert len(report["skipped"]) == 4
    for item in report["skipped"]:
        assert set(item) <= set(declared["properties"]), item
        assert set(declared["required"]) <= set(item)
    assert "waiting" not in SITE_DATA_HINT["properties"], "nothing waits: every origin is in place"


@pytest.mark.skipif(shutil.which("node") is None, reason="needs node")
def test_the_storage_scripts_fill_and_read_for_real(tmp_path):
    harness = tmp_path / "storage.js"
    harness.write_text(
        "const store = () => { const s = new Map(); return {\n"
        "  getItem: (k) => (s.has(k) ? s.get(k) : null), setItem: (k, v) => { s.set(k, String(v)) },\n"
        "  get length() { return s.size }, key: (i) => [...s.keys()][i] } };\n"
        "Object.defineProperty(globalThis, 'localStorage', { value: store(), configurable: true });\n"
        "Object.defineProperty(globalThis, 'sessionStorage', { value: store(), configurable: true });\n"
        "const [local, session, read] = JSON.parse(process.argv[2]);\n"
        "const counts = [eval(local), eval(session)];\n"
        "console.log(JSON.stringify({counts, local: eval(read), session: sessionStorage.getItem('t')}));\n",
        encoding="utf-8",
    )
    items = {"theme": "dark", "quote": "a'b\"c</script>"}
    scripts = [sd.fill("localStorage", items), sd.fill("sessionStorage", {"t": "1"}), sd.READ_LOCAL]
    out = subprocess.run(
        ["node", str(harness), json.dumps(scripts)], capture_output=True, text=True, check=False
    )
    assert out.returncode == 0, out.stderr
    assert json.loads(out.stdout) == {"counts": [2, 1], "local": items, "session": "1"}


# ---- open_session restores before the first page ------------------------------


class OpenedDriver:
    session_id = "new-1"
    current_url = APP + "/"
    title = "App"

    def __init__(self, order):
        self.order = order

    def get_window_size(self):
        return {"width": 1, "height": 2}

    def get(self, url):
        self.order.append("get")


def opened(actions, monkeypatch, spare, data, **kw):
    order = []
    spare.order = order
    monkeypatch.setattr(actions.grid, "open", lambda name, insecure=False: OpenedDriver(order))
    monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi(order=order)))
    return actions.open_session(url=APP + "/", site_data=data, **kw), order


FULL = snapshot(
    cookies=[cookie("sid", "app.example.com")],
    origins={APP: {"a": "1"}},
    session={"origin": APP, "items": {"t": "1"}},
)


def test_open_session_restores_everything_before_the_first_page(actions, monkeypatch, spare):
    result, order = opened(actions, monkeypatch, spare, FULL)
    assert order == ["cookie", "spare", MAIN, "get"]
    assert result["site_data"] == {"restored": ["app.example.com"], "skipped": [], "uri": LIST}
    assert "_site_data_pending" not in result


def test_an_insecure_browser_gets_no_saved_site_data(actions, monkeypatch, spare):
    """A browser that accepts any certificate would hand saved cookies for
    every site to whoever sits in the middle (security review, #49)."""
    result, order = opened(actions, monkeypatch, spare, FULL, insecure=True)
    assert order == ["get"], "nothing set, no tab"
    assert result["site_data"] == {
        "restored": [], "skipped": [{"reason": "an insecure browser gets no saved site data"}],
        "uri": LIST,
    }


def test_open_session_says_nothing_without_site_data(actions, monkeypatch, spare):
    for empty in (None, {}, snapshot()):
        result, order = opened(actions, monkeypatch, spare, empty)
        assert order == ["get"]
        assert "site_data" not in result


def test_a_restore_with_nothing_to_say_carries_no_hint(actions, monkeypatch, spare):
    data = snapshot(cookies=[cookie("old", "app.example.com", expiry=int(NOW) - 1)])
    result, order = opened(actions, monkeypatch, spare, data)
    assert order == ["get"] and "site_data" not in result
  • Step 3: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_restore.py Expected: FAIL — AttributeError: module ... has no attribute 'spare_tab' (from the fixture) and READ_LOCAL.

  • Step 4: Implement — kubed/selenium_flow/core/site_data.py.

In the module docstring, replace the sentence Storage is kept per origin, keyed by the page's own ``location.origin`` because that is the string the preload script compares. with Storage is kept per origin, as ``location.origin`` spells it.

Add after from ..errors import message as failure_text (line 26):

from .browser import ServiceWorkerAnswered, spare_tab

Delete the MARKER constant and its two comment lines (31–33). After the BIDI_DOWN constant add:

# Why an origin's storage was not read: its own service worker answered the
# spare tab (spec round 2, *Service workers at save time*).
SW_REASON = "a service worker answered: save while on this site"

In merge, the marker filter goes (the marker is gone). Replace

            "session": {
                k: v for k, v in (captured.get("session") or {}).items()
                if not k.startswith(MARKER)
            },

with

            "session": dict(captured.get("session") or {}),

Replace preload_source (from def preload_source through its ) closing the return, lines 136–152) with:

# Evaluated in a spare tab standing on an origin: its localStorage, whole.
READ_LOCAL = (
    "(() => { const out = {}; for (let i = 0; i < localStorage.length; i++) "
    "{ const k = localStorage.key(i); out[k] = localStorage.getItem(k); } "
    "return out; })()"
)


def fill(store: str, items: dict) -> str:
    """An expression that sets every item in ``store`` (``localStorage`` or
    ``sessionStorage``) and evaluates to how many it set."""
    return (
        "(() => { const items = " + json.dumps(items) + "; "
        "for (const [k, v] of Object.entries(items)) " + store + ".setItem(k, v); "
        "return Object.keys(items).length; })()"
    )


def restorable(data: dict) -> bool:
    """Whether a snapshot holds anything a restore could put back."""
    data = data or {}
    return bool(
        data.get("cookies") or data.get("origins")
        or (data.get("session") or {}).get("items")
    )


def _why(exc: Exception) -> str:
    return SW_REASON if isinstance(exc, ServiceWorkerAnswered) else failure_text(exc)

Delete _add_preload (lines 427–429).

Replace everything from def restore( to the end of the file (restore, arrive, retire, replace; lines 492–604) with:

def _restore_cookies(bidi, stored: list, now: float, skipped: list) -> list[dict]:
    """Set every live, usable cookie; return the ones the browser kept. Each
    one refused, dropped or unusable is added to ``skipped``."""
    from selenium.webdriver.common.bidi.storage import BytesValue, PartialCookie

    cookies = live_cookies([c for c in stored if _usable(c)], now)
    # One bad entry is skipped, not the whole restore.
    skipped.extend(_malformed(c) for c in stored if not _usable(c))
    sent = []
    for c in cookies:
        try:
            bidi.storage.set_cookie(PartialCookie(
                c["name"],
                BytesValue(c.get("value_type") or "string", c.get("value")),
                c["domain"], path=c.get("path"), http_only=c.get("http_only"),
                secure=c.get("secure"), same_site=_same_site(c),
                expiry=c.get("expiry"),
            ))
            sent.append(c)
        except Exception as e:  # noqa: BLE001 - one refusal must not stop the rest
            skipped.append({
                "cookie": c.get("name"), "domain": c.get("domain"),
                "reason": failure_text(e),
            })
    # A browser can refuse a cookie without an error; only the jar says.
    kept = _kept(bidi, sent)
    if kept is None:
        kept = sent
    skipped.extend(
        {"cookie": c.get("name"), "domain": c.get("domain"),
         "reason": "the browser did not keep it"}
        for c in sent if c not in kept
    )
    return kept


def restore(bidi, data: dict, now: float) -> dict:
    """Put a snapshot into a browser before its first page: every live
    cookie, then each origin's localStorage in a spare tab, then the saved
    sessionStorage in the main tab, which is left on about:blank for the
    landing. Everything is in place when this returns.

    Best effort, and nothing raises: the report names each host that came
    back and each item that did not, with why.
    """
    skipped: list[dict] = []
    kept: list[dict] = []
    filled: list[str] = []
    try:
        kept = _restore_cookies(bidi, data.get("cookies") or [], now, skipped)
        local = {
            o: e["local"] for o, e in (data.get("origins") or {}).items()
            if isinstance(e, dict) and e.get("local")
        }
        if local:
            try:
                with spare_tab(bidi) as run:
                    for origin, items in local.items():
                        try:
                            run(origin, fill("localStorage", items))
                            filled.append(origin)
                        except Exception as e:  # noqa: BLE001 - one origin, not the rest
                            skipped.append({"site": origin, "reason": _why(e)})
            except Exception as e:  # noqa: BLE001 - no tab: each origin not yet filled
                done = {s.get("site") for s in skipped}
                skipped.extend(
                    {"site": o, "reason": failure_text(e)}
                    for o in local if o not in filled and o not in done
                )
        session = data.get("session") or {}
        if session.get("origin") and session.get("items"):
            try:
                main = bidi.current_window_handle
                with spare_tab(bidi, context=main) as run:
                    run(session["origin"], fill("sessionStorage", session["items"]))
                    # The stand-in page must never be what an open with no url
                    # leaves on screen; the tab keeps its sessionStorage.
                    with contextlib.suppress(Exception):
                        bidi.browsing_context.navigate(
                            context=main, url="about:blank", wait="complete"
                        )
                filled.append(session["origin"])
            except Exception as e:  # noqa: BLE001 - the rest stands
                skipped.append({"site": session["origin"], "reason": _why(e)})
    except Exception as e:  # noqa: BLE001 - BiDi failing is a report, not a crash
        skipped.append({"reason": failure_text(e)})
    return {
        "restored": _hosts({"cookies": kept, "origins": {o: {} for o in filled}}),
        "skipped": skipped,
        "uri": LIST_URI,
    }

(contextlib is already imported at the top of the module; keep it.)

kubed/selenium_flow/core/actions.py — in open_session, replace from restored = pending = None (line 410) through restored, pending = site_data_module.arrive(restored, pending, current_url) (line 433) with:

        report = None
        if site_data_module.restorable(site_data or {}):
            if insecure:
                # It accepts any certificate, so anyone in the middle would get
                # every saved cookie. Nothing is set, and nothing is deleted.
                report = {
                    "restored": [], "skipped": [{"reason": INSECURE_SKIP}],
                    "uri": site_data_module.LIST_URI,
                }
            else:
                with self.grid.bidi(session_id) as bidi:
                    report = site_data_module.restore(bidi, site_data, time.time())

        current_url, title = "about:blank", ""
        if url:
            driver.get(url)
            current_url, title = driver.current_url, driver.title

and replace the tail (lines 463–466)

        if restored is not None:
            result["site_data"] = restored
            result["_site_data_pending"] = pending
        return result

with

        if report and (report["restored"] or report["skipped"]):
            result["site_data"] = report
        return result

Update the docstring's last paragraph to: A session's saved site data is restored here — cookies, every origin's storage, the saved sessionStorage — before the first page loads, except into an ``insecure`` browser, which gets none. Delete retire_site_data (lines 479–492) whole.

kubed/selenium_flow/spec/schemas.py — replace SITE_DATA_HINT (lines 24–67) with:

# Present only when a call did something with site data, so it is left out of
# `required` and of every response that never carries it.
SITE_DATA_HINT = {
    "type": "object",
    "description": (
        "Present only when site data was restored, skipped or forgotten in "
        "this call."
    ),
    "properties": {
        "restored": {
            "type": "array",
            "items": {"type": "string"},
            "description": (
                "Each host whose saved cookies or storage are in the browser "
                "now (app.example.com). All of it is in place before the call "
                "returns."
            ),
        },
        "forgotten": {
            "type": "integer",
            "description": "Sites deleted because restore_site_data was false.",
        },
        "skipped": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "cookie": {"type": "string", "description": "A cookie's name."},
                    "domain": {"type": "string", "description": "Its domain."},
                    "site": {
                        "type": "string",
                        "description": "An origin whose storage did not come back.",
                    },
                    "reason": {"type": "string"},
                },
                "required": ["reason"],
            },
            "description": "Items that could not be restored, each with a reason.",
        },
        "uri": {"type": "string", "description": "Where to read what is saved."},
    },
}
  • Step 5: Delete the round 1 tests this replaces — they test the preload script, the arrival hint and the old restore, or moved to tests/test_site_data_restore.py:
python3 $S/drop.py tests/test_site_data.py \
  test_the_restore_marker_is_never_saved \
  test_the_preload_script_fills_only_its_own_origin_once_per_tab \
  test_restore_sets_live_cookies_and_one_preload_script \
  test_a_refused_cookie_is_skipped_not_fatal \
  test_restore_with_no_storage_adds_no_preload_script \
  test_a_bidi_failure_as_a_whole_is_reported_not_raised \
  test_retire_removes_the_script_and_never_raises \
  OpenedDriver _open _redirected_to \
  test_open_session_restores_before_the_first_page_loads \
  test_an_insecure_browser_gets_no_saved_site_data \
  test_a_redirect_to_another_saved_origin_restores_that_one \
  test_a_redirect_off_the_saved_origins_leaves_the_requested_one_waiting \
  test_open_session_says_nothing_without_site_data \
  test_a_none_same_site_without_secure_is_restored_as_lax \
  test_a_cookie_the_browser_did_not_keep_is_skipped \
  test_an_unreadable_jar_trusts_the_set \
  test_replace_swaps_the_script_for_the_origins_still_waiting \
  test_actions_retire_replaces_and_keeps_the_old_id_when_bidi_is_down \
  test_every_skipped_item_restore_emits_is_declared \
  test_a_bidi_failure_never_leaks_the_grid_credential_into_the_report \
  test_replace_keeps_the_old_id_when_the_remove_fails \
  test_replace_answers_empty_when_the_add_fails_after_the_remove \
  test_one_malformed_cookie_is_skipped_not_the_whole_restore \
  test_a_nonnumeric_expiry_is_skipped_not_the_whole_restore \
  test_a_failure_after_the_script_is_added_never_loses_its_id \
  PRELOAD_HARNESS \
  test_the_preload_script_fills_its_own_origin_once_per_tab_and_no_other

Expected: tests/test_site_data.py: dropped 29. grep -n 'preload\|retire\|arrive\|MARKER' tests/test_site_data.py then lists only FakeScript's two preload methods, which Task 8 deletes with the class.

  • Step 6: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_restore.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_site_data_surface.py tests/test_openapi.py → PASS; then the whole unit suite → PASS; ruff clean; grep -rn --include='*.py' 'preload_source\|retire_site_data\|\.arrive(\|\bMARKER\b' kubed prints only session/sessions.py lines (its pending code goes in Task 5).

  • Step 7: Break it on purpose — in restore, move the sessionStorage block above the localStorage block; confirm test_cookies_then_each_origins_storage_then_session_storage fails on order; restore.

  • Step 8: Commit

git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/core/actions.py \
  kubed/selenium_flow/spec/schemas.py tests/site_data_fakes.py \
  tests/test_site_data_restore.py tests/test_site_data.py
git commit -m "Site data round 2: restore writes every origin before the first page; the preload script goes" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: The session drops its pending note; a reopen's report rides on the record

Files:

  • Modify: kubed/selenium_flow/session/store.py — SessionRecord gains reopened (+ from_json, delivered).
  • Modify: kubed/selenium_flow/session/sessions.py — resolve (343–370), settle (389–415), replace _site_data_after … _hold_pending (417–616) with _save_site_data and _restorable, open_browser (657–683), remember (702–761), touch.
  • Modify: kubed/selenium_flow/core/site_data.py — merge no longer carries pending (lines 86–87).
  • Modify: kubed/selenium_flow/flows/api.py (run_for's touch), kubed/selenium_flow/flows/run.py (762–768), kubed/selenium_flow/secrets.py (perform_write, 761–762), kubed/selenium_flow/spec/schemas.py (FlowRun, 573–646), kubed/selenium_flow/mcp/tools.py (screenshot comment, 740–741), kubed/selenium_flow/http/admin.py (admin_site_data_forget, 833–868).
  • Rewrite: tests/test_site_data_sessions.py. Modify: tests/test_site_data.py, tests/test_admin_site_data.py, tests/test_openapi.py, tests/test_secrets.py.

Interfaces:

  • Consumes: Task 4's site_data.restorable, the report shape {"restored", "skipped", "uri"}; Task 1's touch/remember.

  • Produces:

    • SessionRecord.reopened: dict — {"browser": session_id, "report": {...}} after a silent reopen, else {}; SessionRecord.delivered() -> SessionRecord clears it.
    • SessionManager.remember(name, session_id, url="", settings=None, replacing=None, forget_site_data=False, report: dict | None = None) -> str — a non-empty report is held on the new record for that browser; any other bind clears what was held.
    • SessionManager.touch(name, *urls, browser=None) -> dict | None — returns, and clears, the held report when it is this browser's.
    • SessionManager.settle(...) — on a touch, a returned report becomes result["site_data"].
    • flows.api.run_for(...) — a run whose browser was reopened carries the report as the run's top-level site_data; steps never carry site_data (the arrival hint is gone; FlowRun declares it at run level).
    • secrets.perform_write(...) — a bound write carries the report the same way.
    • Deleted: SessionManager._site_data_after, _without, _waiting, _hold_pending; every pending key.
  • Step 1: Write the failing tests — replace tests/test_site_data_sessions.py whole:

"""The session's side of site data: a save stored and never returned, a
restore on open and on a silent reopen, and the one report each makes."""

import json
from dataclasses import replace

import pytest

from kubed.selenium_flow.core import site_data
from tests.conftest import NAMED, RecordingActions, manager

URL = "https://app.example.com/x"
REPORT = {"restored": ["app.example.com"], "skipped": [], "uri": "session://site-data"}


class SiteActions(RecordingActions):
    def __init__(self):
        super().__init__()
        self.site_data_seen = []

    def open_session(self, url=None, site_data=None, **kw):
        self.site_data_seen.append(site_data)
        opened = super().open_session(url=url, **kw)
        if site_data:
            opened["site_data"] = dict(REPORT)
        return opened

    def save_site_data(self, session_id, url=None):
        return {
            "url": URL,
            "title": "t",
            site_data.CAPTURED: {
                "cookies": [{"name": "a", "value": "1", "domain": "app.example.com"}],
                "origin": "https://app.example.com",
                "local": {"k": "v"},
                "session": {},
            },
        }


def opened_with_save():
    m = manager(SiteActions())
    m.open_browser(NAMED)
    m.act(NAMED, lambda s: m.actions.save_site_data(s))
    return m


def reopened(m, **kw):
    m.end_browser(NAMED)
    return m.open_browser(NAMED, **kw)


def reaped(m):
    """The Grid took the browser: the next call reopens it on its own."""
    m.actions.grid.alive.clear()


def test_a_save_is_stored_and_never_returned_raw(named_caller):
    m = manager(SiteActions())
    m.open_browser(NAMED)
    result = m.act(NAMED, lambda s: m.actions.save_site_data(s))
    assert site_data.CAPTURED not in result
    assert result["saved"]["cookies"] == 1 and result["uri"] == "session://site-data"
    assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]


def test_open_restores_what_was_saved_and_says_so(named_caller):
    m = opened_with_save()
    told = reopened(m)
    assert m.actions.site_data_seen[-1]["cookies"]
    assert told["site_data"] == REPORT
    assert "_site_data_pending" not in told
    assert "pending" not in m.store.get(NAMED).site_data


def test_restore_off_deletes_the_snapshot_and_keeps_the_history(named_caller):
    m = opened_with_save()
    told = reopened(m, restore_site_data=False)
    assert m.actions.site_data_seen[-1] is None
    assert told["site_data"] == {"forgotten": 1}
    record = m.store.get(NAMED)
    assert record.site_data == {}
    assert "https://app.example.com" in [v["origin"] for v in record.history]


def test_restore_site_data_accepts_a_string_false(named_caller):
    m = opened_with_save()
    reopened(m, restore_site_data="false")
    assert m.store.get(NAMED).site_data == {}


def test_a_failed_open_does_not_erase_what_was_saved(named_caller):
    """Declining a restore deletes only once the clean browser is open: a
    transient Grid failure must not cost the saved sign-in (Copilot, #49)."""
    m = opened_with_save()
    m.end_browser(NAMED)

    def refuse(**_):
        raise RuntimeError("the grid is full")

    m.actions.open_session = refuse
    with pytest.raises(RuntimeError):
        m.open_browser(NAMED, restore_site_data=False)
    assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]


def test_nothing_saved_means_no_hint(named_caller):
    m = manager(SiteActions())
    assert "site_data" not in m.open_browser(NAMED)


def test_an_insecure_open_keeps_what_was_saved(named_caller):
    """No restore into an insecure browser, but nothing is deleted either:
    the next secure browser gets it all back."""
    m = opened_with_save()
    reopened(m, insecure=True)
    assert m.store.get(NAMED).site_data["origins"]


def test_remember_keeps_site_data(named_caller):
    m = opened_with_save()
    m.open_browser(NAMED)
    m.open_browser(NAMED)
    assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]


def test_describe_summarises_site_data(named_caller):
    m = opened_with_save()
    assert m.describe(NAMED)["site_data"] == {"sites": 1, "uri": "session://site-data"}


# ---- a silent reopen says what came back, on the first result after it --------


def test_the_call_that_reopens_a_reaped_browser_says_what_came_back(named_caller):
    m = opened_with_save()
    reaped(m)
    first = m.act(NAMED, lambda s: {"url": "https://elsewhere.example.com/"})
    assert m.actions.site_data_seen[-1]["cookies"], "the reopen restored"
    assert first["site_data"] == REPORT
    assert "site_data" not in m.act(NAMED, lambda s: {"url": "https://elsewhere.example.com/"})
    assert m.store.get(NAMED).reopened == {}


def test_a_report_waits_for_a_call_that_finishes(named_caller):
    m = opened_with_save()
    reaped(m)

    def fails(_):
        raise RuntimeError("the page broke")

    with pytest.raises(RuntimeError):
        m.act(NAMED, fails)
    assert m.act(NAMED, lambda s: {"url": URL})["site_data"] == REPORT


def test_a_report_is_never_handed_to_a_result_from_another_browser(named_caller):
    m = opened_with_save()
    reaped(m)
    m.resolve(NAMED)
    told = {"url": URL}
    m.settle(NAMED, told, browser="an-older-browser")
    assert "site_data" not in told
    assert m.act(NAMED, lambda s: {"url": URL})["site_data"] == REPORT


def test_an_open_drops_a_report_nobody_collected(named_caller):
    m = opened_with_save()
    reaped(m)
    m.resolve(NAMED)
    assert m.open_browser(NAMED)["site_data"] == REPORT, "the open's own report"
    assert "site_data" not in m.act(NAMED, lambda s: {"url": URL})


# ---- a flow is settled the way a single call is ---------------------------------


class FlowActions(SiteActions):
    def navigate(self, session_id, url=None, **kw):
        return {"url": url, "title": "w"}


def flow_world(tmp_path, steps):
    from kubed.selenium_flow.config import Settings
    from kubed.selenium_flow.server import SeleniumMCP

    server = SeleniumMCP(Settings(
        grid={"url": "http://grid.invalid:4444"}, flow={"data_dir": str(tmp_path)},
    ))
    server.flows.save(NAMED, "login", {"steps": steps})
    m = manager(FlowActions())
    m.open_browser(NAMED)
    return server.flows, m


def run(store, m):
    from kubed.selenium_flow.flows import api as flowapi

    return flowapi.run_for(store, m.actions, m, NAMED, "login", verbose=True)


def test_a_flow_that_saves_keeps_the_data_and_never_reports_the_capture(
    named_caller, tmp_path
):
    store, m = flow_world(tmp_path, [{"tool": "save_site_data", "args": {}, "return": True}])
    report = run(store, m)
    assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]
    assert site_data.CAPTURED not in json.dumps(report)
    assert report["steps"][0]["result"]["saved"]["cookies"] == 1


def test_a_flow_run_that_reopened_the_browser_says_so_once(named_caller, tmp_path):
    store, m = flow_world(tmp_path, [{"tool": "navigate", "args": {"url": "https://w.example.com/"}}])
    m.act(NAMED, lambda s: m.actions.save_site_data(s))
    reaped(m)
    report = run(store, m)
    assert report["site_data"] == REPORT
    assert "site_data" not in report["steps"][0]
    assert "site_data" not in run(store, m)


# ---- a save never reverts what landed while it worked -------------------------


def test_the_capture_is_stripped_even_when_the_store_fails(named_caller):
    m = opened_with_save()
    result = m.actions.save_site_data("x")

    def down(name):
        raise ConnectionError("redis is down")

    m.store.get = down
    with pytest.raises(ConnectionError):
        m.settle(NAMED, result, touch=False)
    assert site_data.CAPTURED not in result


def save_elsewhere(m, origin):
    def save(r):
        data = dict(r.site_data)
        data["origins"] = {**data.get("origins", {}), origin: {"local": {"k": "1"}}}
        return r.with_site_data(data)

    return lambda: m.store.update(NAMED, save)


def test_ending_a_browser_keeps_a_save_that_landed_while_it_quit(named_caller):
    m = opened_with_save()
    real = m.actions.end_browser

    def end(session_id):
        save_elsewhere(m, "https://late.example.com")()
        return real(session_id)

    m.actions.end_browser = end
    m.end_browser(NAMED)
    record = m.store.get(NAMED)
    assert not record.attached
    assert record.site_data["origins"].get("https://late.example.com") is not None


def test_a_save_from_a_replaced_browser_keeps_nothing(named_caller):
    """open_session(restore_site_data=false) on another request deletes the
    data; a save still finishing on the old browser must not bring it back."""
    m = manager(SiteActions())
    m.open_browser(NAMED)

    def save_while_replaced(resolved):
        m.store.update(NAMED, lambda r: replace(r, session_id="newer", site_data={}))
        return m.actions.save_site_data(resolved)

    told = m.act(NAMED, save_while_replaced)
    assert site_data.CAPTURED not in told
    assert told["saved"]["sites"] == [] and told["saved"]["cookies"] == 0
    assert told["saved"]["skipped"][0]["reason"].startswith("another browser took")
    assert m.store.get(NAMED).site_data == {}


def test_a_stale_result_does_not_move_the_newer_browsers_page_or_window(named_caller):
    """A result from a browser the record no longer names must not write its
    page or size over the newer browser's: a reap would reopen B at A's page
    (Copilot, #50). The TTL still slides."""
    m = opened_with_save()
    m.store.update(NAMED, lambda r: r.at("https://b.test/"))

    def meanwhile(resolved):
        m.store.update(NAMED, lambda r: replace(r, session_id="newer"))
        return {"url": "https://a.test/page", "width": 640, "height": 480}

    m.act(NAMED, meanwhile, reshapes=True)
    record = m.store.get(NAMED)
    assert record.session_id == "newer"
    assert record.url == "https://b.test/"
    assert record.settings.get("width") != 640


def test_a_result_from_the_browser_held_still_moves_the_page(named_caller):
    m = opened_with_save()
    m.act(NAMED, lambda s: {"url": "https://a.test/page", "width": 640, "height": 480},
          reshapes=True)
    record = m.store.get(NAMED)
    assert record.url == "https://a.test/page"
    assert record.settings["width"] == 640

In tests/test_secrets.py, add after test_a_direct_bound_write_still_remembers_an_untouched_page:

async def test_a_bound_write_after_a_silent_reopen_says_what_came_back(
    tmp_path, monkeypatch
):
    """A bound write is a call like any other: the first one after a reopen
    carries the reopen's report, which `touch` hands over."""
    from kubed.selenium_flow.server import SeleniumMCP

    from .conftest import NAMED, TOKEN

    monkeypatch.delenv("SECRETS_DIRS", raising=False)
    monkeypatch.delenv("FLOW_DATA_DIR", raising=False)
    make_secret(
        tmp_path, "nextcloud", password="hunter2",
        **{ALLOWED_URLS: "https://nc.example.com"},
    )
    server = SeleniumMCP(Settings(
        grid={"url": "http://grid.invalid:4444"},
        auth={"token": TOKEN},
        secrets={"dirs": str(tmp_path)},
    ))
    monkeypatch.setattr(server.sessions, "name", lambda: NAMED)
    monkeypatch.setattr(server.sessions, "resolve", lambda name: "browser-1")
    monkeypatch.setattr(
        server.actions, "page",
        lambda sid: {"url": "https://nc.example.com/login", "title": "Log in"},
    )
    monkeypatch.setattr(
        server.actions, "write",
        lambda sid, text, **kw: {
            "value": None, "url": "https://nc.example.com/home", "title": "Home",
        },
    )
    report = {"restored": ["nc.example.com"], "skipped": [], "uri": "session://site-data"}
    monkeypatch.setattr(server.sessions, "touch", lambda name, url, browser=None: report)

    write = await server.mcp.get_tool("write")
    result = write.fn(
        selector={"css": "#password"},
        secret={"name": "nextcloud", "key": "password"},
    )
    assert result["site_data"] == report

In tests/test_openapi.py, replace test_a_flow_step_declares_the_site_data_it_reports (lines 346–350) with:

async def test_a_flow_run_declares_the_site_data_it_reports(spec):
    """A reopen's report rides on the run; no step carries one any more."""
    from kubed.selenium_flow.spec.schemas import SITE_DATA_HINT

    run = spec["components"]["schemas"]["FlowRun"]
    assert run["properties"]["site_data"] == SITE_DATA_HINT
    assert "site_data" not in run["properties"]["steps"]["items"]["properties"]

In tests/test_admin_site_data.py, delete the "pending" entry from DATA (its comment line and the two lines of the dict, 38–40) and the assertion assert left["pending"] == {...} with its continuation line (102–103).

And drop the merge test that pinned pending:

python3 $S/drop.py tests/test_site_data.py test_merge_keeps_pending
  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_sessions.py tests/test_openapi.py tests/test_secrets.py Expected: FAIL — among others test_the_call_that_reopens_a_reaped_browser_says_what_came_back (no site_data: Task 4 left the reopen unannounced), test_a_flow_run_declares_the_site_data_it_reports (KeyError: 'site_data'), test_a_bound_write_after_a_silent_reopen_says_what_came_back (KeyError: 'site_data').

  • Step 3: Implement the record — kubed/selenium_flow/session/store.py. After the site_data field add:
    # A silent reopen's site data report, held until the first result from
    # that browser carries it: {"browser": id, "report": {...}}, or {}.
    reopened: dict = field(default_factory=dict)

In from_json, read it beside the others — reopened = data.get("reopened") after site_data = data.get("site_data") — and pass reopened=reopened if isinstance(reopened, dict) else {}, as the last argument to cls(...). After with_site_data add:

    def delivered(self) -> SessionRecord:
        """The same record, its reopen report handed to a caller."""
        return replace(self, reopened={})
  • Step 4: Implement the session — kubed/selenium_flow/session/sessions.py.

In resolve, replace from saved = self._restorable(record) (line 350) to the end of the method (return opened["session_id"], line 370) with:

        saved = self._restorable(record)
        opened = self.actions.open_session(
            url=record.url or None,
            **({"site_data": saved} if saved else {}),
            **(record.settings or {}),
        )
        # Nobody asked for this reopen, so the first result after it says what
        # came back: the record holds the report until `touch` hands it over.
        # A reopen alongside this one may bind first; then act on its browser.
        return self.remember(
            name,
            opened["session_id"],
            opened.get("url", record.url or ""),
            record.settings,
            replacing=record.session_id,
            report=opened.get("site_data"),
        )

Replace settle and everything after it up to (not including) def open_browser — that is settle, _site_data_after, _without, _waiting, _restorable, _hold_pending, lines 389–616 — with:

    def settle(
        self,
        name: str,
        result,
        *,
        browser: str | None = None,
        reshapes: bool = False,
        touch: bool = True,
    ) -> None:
        """What a finished action means for the record, in place on ``result``.

        The one place a result is stripped of its private capture and the
        capture stored, so a flow step reaches it the same way a single call
        does. ``touch=False`` is for the flow runner, which touches once per
        run rather than once per step — and so carries a reopen's report on
        the run, not on a step.

        ``browser`` is the one that produced ``result``: once the record names
        another, its page is not recorded and its save is not kept.
        """
        if not isinstance(result, dict):
            return
        if touch:
            told = self.touch(name, result.get("url"), browser=browser)
            if told:
                result["site_data"] = told
        if reshapes:
            self.reshape(name, result, browser=browser)
        self._save_site_data(name, result, browser)

    def _save_site_data(
        self, name: str, result: dict, producer: str | None = None
    ) -> None:
        """A captured save, stored on the record and replaced in the result by
        a short receipt, so no caller ever sees a value.

        Written through ``store.update``, applied to the record as it is then,
        and only by the browser that captured it.
        """
        # Popped before the store is asked anything, so a store that fails
        # cannot leave the capture — every value — in what the caller gets.
        captured = result.pop(site_data_module.CAPTURED, None)
        if not captured:
            return
        receipt: dict = {}

        def save(r: SessionRecord) -> SessionRecord | None:
            if producer is not None and r.session_id != producer:
                # Another browser holds the session now — perhaps one opened
                # with restore_site_data=false. What this one captured is not
                # its to keep (Copilot, #50).
                receipt["saved"] = {
                    "cookies": 0, "sites": [],
                    "skipped": [{"reason": REPLACED_BEFORE_SAVE}],
                }
                return None
            data, receipt["saved"] = site_data_module.merge(
                r.site_data, captured, time.time()
            )
            return r.with_site_data(data)

        self.store.update(name, save)
        if "saved" in receipt:
            result["saved"] = receipt["saved"]
            result["uri"] = site_data_module.LIST_URI

    @staticmethod
    def _restorable(record: SessionRecord) -> dict:
        """The snapshot a new browser is given, or {} when there is none."""
        if not site_data_module.restorable(record.site_data):
            return {}
        return dict(record.site_data)

In open_browser, make the forgotten count safe for a snapshot whose cookies name no host — forgotten = (site_data_module.summary(saved) or {"sites": 0})["sites"] — and delete the line self._hold_pending(name, opened, {"announce": False}).

In remember, add the parameter report: dict | None = None after forget_site_data: bool = False, add to its docstring:

        ``report`` is a silent reopen's site data report: the record holds it
        for this browser until `touch` hands it to the first result. Any other
        bind drops whatever an earlier reopen still held.

and add to the SessionRecord(...) built in bound, after site_data=...:

                reopened={"browser": session_id, "report": report} if report else {},

Replace the end of touch — from ttl = self.store.ttl to self.store.update(name, at) — with:

        ttl = self.store.ttl
        told: dict = {}

        def at(r: SessionRecord) -> SessionRecord:
            if browser and r.session_id != browser:
                return r
            note = r.reopened
            ours = note.get("browser") == r.session_id
            told["report"] = note.get("report") if ours else None
            return r.at(*urls, ttl=ttl).delivered()

        self.store.update(name, at)
        return told.get("report")

change its return annotation to -> dict | None, and append to its docstring: Returns a silent reopen's report the first time this browser is touched after it, and never again.

  • Step 5: Implement the rest of the surface

kubed/selenium_flow/core/site_data.py, in merge: delete

    if (existing or {}).get("pending"):
        data["pending"] = existing["pending"]

kubed/selenium_flow/flows/api.py, the last two lines of run_for become:

    told = sessions.touch(session, *visited, browser=resolved)
    if told:
        # The run's browser replaced a reaped one: what came back, once.
        report["site_data"] = told
    return report

kubed/selenium_flow/flows/run.py, replace

            # Before the result is copied for the report: the hook strips the
            # private capture from `raw` and may add the site data hint.
            if after_step is not None:
                after_step(tool, raw)
            result = _clean(raw, guarded, hidden)
            if isinstance(raw, dict) and raw.get("site_data"):
                entry["site_data"] = raw["site_data"]

with

            # Before the result is copied for the report: the hook strips the
            # private capture from `raw`.
            if after_step is not None:
                after_step(tool, raw)
            result = _clean(raw, guarded, hidden)

kubed/selenium_flow/secrets.py, the end of perform_write becomes:

    safe = None if flowrun.taints(result.get("url"), hidden) else shown.get("url")
    told = sessions.touch(name, safe, browser=resolved)
    if told:
        # The first call after a silent reopen says what came back.
        shown["site_data"] = told
    return shown

kubed/selenium_flow/spec/schemas.py, in FlowRun: delete the step item's "site_data": SITE_DATA_HINT, line, and add after the run's "title": {"type": "string"},:

            # Present only when the run's browser was reopened after a reap.
            "site_data": SITE_DATA_HINT,

kubed/selenium_flow/mcp/tools.py, in screenshot, the comment's last item becomes:

        # - `site_data`: the first call after a silent reopen says what came
        #   back (``sessions.settle``) — dropped here, it is never said at all.

kubed/selenium_flow/http/admin.py, in admin_site_data_forget: replace the docstring with

        """Forget one site. Parent-domain cookies stay: other sites use them.

        It changes the session's store and nothing else: the history stays,
        and a browser open now keeps what it has — only the next one opened
        comes back without it. A save after this saves the site again.
        """

and delete the pending block inside forget (the comment # Pending origins are restores not yet consumed;… and the pending = left.get("pending") if that follows, lines 851–860), leaving return record.with_site_data(left).

  • Step 6: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_sessions.py tests/test_sessions.py tests/test_secrets.py tests/test_openapi.py tests/test_admin_site_data.py tests/test_history.py tests/test_screenshot_saving.py tests/test_flowrun.py → PASS; then the whole suite → PASS; ruff clean; grep -rnw --include='*.py' 'pending\|_hold_pending\|retire_site_data' kubed prints nothing.

  • Step 7: Break it on purpose — in touch, return r.at(*urls, ttl=ttl) without .delivered(); confirm test_the_call_that_reopens_a_reaped_browser_says_what_came_back fails on its second call; restore.

  • Step 8: Commit

git add kubed/selenium_flow/session/store.py kubed/selenium_flow/session/sessions.py \
  kubed/selenium_flow/core/site_data.py kubed/selenium_flow/flows/api.py \
  kubed/selenium_flow/flows/run.py kubed/selenium_flow/secrets.py \
  kubed/selenium_flow/spec/schemas.py kubed/selenium_flow/mcp/tools.py \
  kubed/selenium_flow/http/admin.py tests/test_site_data_sessions.py tests/test_site_data.py \
  tests/test_admin_site_data.py tests/test_openapi.py tests/test_secrets.py
git commit -m "Site data round 2: no pending note; a reopen's report waits on the record for the first result" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: The Site data view — the snapshot by host

Files:

  • Modify: kubed/selenium_flow/core/site_data.py — _Jar.__init__ (177–186), _site (244–270), _rows (288–329), view, views, site_view (332–351), forget (359–378); add history_hosts. (matching_secrets stays for Task 7.)
  • Modify: kubed/selenium_flow/http/admin.py — the session row (596–610, 629–630), admin_site_data (809–825), new admin_site_data_clear.
  • Modify: kubed/selenium_flow/mcp/resources.py — SITE_DESCRIPTION, _saved, site_listing, one_site.
  • Modify: kubed/selenium_flow/spec/schemas.py — SITE_DATA_SCHEMAS (997–1100).
  • Create: tests/test_site_data_views.py. Rewrite: tests/test_admin_site_data.py. Modify: tests/test_site_data.py (drop the view tests), tests/test_site_data_surface.py (one new test).

Interfaces:

  • Consumes: the snapshot shape (Task 4); SessionRecord.history (Task 1).

  • Produces:

    • site_data.history_hosts(history) -> list[str] — the hosts of a history, most recent first, each once.
    • site_data.view(data: dict, history=()) -> dict → {"sites": [row], "saved_at": float | None, "uri": "session://site-data"}; row {"site", "uri", "cookies": int, "storage": [{"origin", "local_storage": int, "session_storage": int}]} (storage is [] for a cookie-only host). One row per host the snapshot holds data for — its storage hosts (sessionStorage included when it has items) and the cookie domains no storage host covers. Hosts in history first, most recent first, then the rest alphabetically. No secrets, no saved flag: every row has something saved.
    • site_data.views(data, history=()) -> (listing, details); site_data.site_view(data, site) -> dict | None; detail {"site", "uri", "cookies": [cookie view], "storage": [{"origin", "local_storage": {...}, "session_storage": {...}}], "own_cookies": [name], "kept_shared": [{"name", "domain", "path"}]}.
    • site_data.forget(data, host) — as before, and the saved sessionStorage goes too when its origin is on host; saved_at stays (forgetting is not a save).
    • GET {prefix}/admin/sessions/{key}/site-data → {"key", **view(record.site_data, record.history), "details"}; DELETE {prefix}/admin/sessions/{key}/site-data (Clear) → {"cleared": [host, …]} and the snapshot becomes {}; DELETE …/site-data/{site} unchanged (404 when nothing of its own).
    • Session row: site_data_count = len(view["sites"]), site_data_rev = json.dumps([saved_at, [hosts]]) — a save moves the time, a Forget or Clear moves the hosts.
    • session://site-data and GET {prefix}/site-data list in history order; session://site-data/{site} unchanged.
  • Step 1: Write the failing tests — tests/test_site_data_views.py:

"""The snapshot by host: the Site data tab, session://site-data, and Forget.
Pure, over round 2's snapshot shape."""

import pytest

from kubed.selenium_flow.core import site_data as sd

from .site_data_fakes import NOW, cookie, snapshot

pytestmark = pytest.mark.unit

APP = "https://app.example.com"


def visit(url, at=NOW):
    return {"origin": sd.origin_of(url), "url": url, "at": at}


def test_one_row_per_host_with_counts_and_both_storages_in_full():
    data = snapshot(
        cookies=[cookie("sid", "app.example.com", value="secret", http_only=True),
                 cookie("ab", ".example.com", value="b1"), cookie("kc", "sso.example.com")],
        origins={APP: {"theme": "dark", "tour": "1"}},
        session={"origin": APP, "items": {"token": "t"}},
    )
    listing = sd.view(data)
    assert listing["saved_at"] == NOW and listing["uri"] == "session://site-data"
    rows = {r["site"]: r for r in listing["sites"]}
    assert rows["app.example.com"] == {
        "site": "app.example.com", "uri": "session://site-data/app.example.com", "cookies": 2,
        "storage": [{"origin": APP, "local_storage": 2, "session_storage": 1}],
    }
    assert rows["sso.example.com"]["storage"] == [], "cookies only"
    one = sd.site_view(data, "app.example.com")
    by_name = {c["name"]: c for c in one["cookies"]}
    assert by_name["sid"]["value"] == sd.MASK, "httpOnly is masked"
    assert by_name["ab"]["value"] == "b1" and by_name["ab"]["shared"] is True
    assert one["storage"] == [
        {"origin": APP, "local_storage": {"theme": "dark", "tour": "1"},
         "session_storage": {"token": "t"}},
    ]
    assert sd.site_view(data, "nope.test") is None


def test_hosts_in_the_history_come_first_then_the_rest_alphabetically():
    data = snapshot(cookies=[cookie("c", "cdn.test"), cookie("a", "ads.test"),
                             cookie("k", "app.test"), cookie("g", "grafana.test")])
    history = [visit("https://grafana.test/d"), visit("https://app.test/x"),
               visit("https://never-saved.test/")]
    assert [r["site"] for r in sd.view(data, history)["sites"]] == [
        "grafana.test", "app.test", "ads.test", "cdn.test",
    ]
    assert [r["site"] for r in sd.view(data)["sites"]] == [
        "ads.test", "app.test", "cdn.test", "grafana.test",
    ]


def test_history_hosts_are_unique_and_newest_first():
    history = [visit("https://a.test/1"), visit("http://a.test:8080/"), visit("https://b.test/")]
    assert sd.history_hosts(history) == ["a.test", "b.test"]
    assert sd.history_hosts([]) == []


def test_empty_session_storage_is_no_row():
    data = snapshot(session={"origin": "https://app.test", "items": {}})
    assert sd.view(data)["sites"] == []
    assert sd.summary(data) is None


def test_session_storage_alone_makes_a_row():
    data = snapshot(session={"origin": "https://app.test", "items": {"t": "1"}})
    assert sd.view(data)["sites"][0]["storage"] == [
        {"origin": "https://app.test", "local_storage": 0, "session_storage": 1},
    ]
    assert sd.summary(data) == {"sites": 1, "uri": "session://site-data"}


def test_one_host_on_two_ports_keeps_each_origins_storage_apart():
    data = snapshot(
        origins={"http://localhost:3000": {"k": "a"}, "http://localhost:8080": {"k": "b"}},
        session={"origin": "http://localhost:8080", "items": {"s": "1"}},
    )
    assert sd.view(data)["sites"][0]["storage"] == [
        {"origin": "http://localhost:3000", "local_storage": 1, "session_storage": 0},
        {"origin": "http://localhost:8080", "local_storage": 1, "session_storage": 1},
    ]
    assert sd.site_view(data, "localhost")["storage"] == [
        {"origin": "http://localhost:3000", "local_storage": {"k": "a"}, "session_storage": {}},
        {"origin": "http://localhost:8080", "local_storage": {"k": "b"}, "session_storage": {"s": "1"}},
    ]


def test_a_parent_cookie_with_no_host_gets_its_own_row():
    data = snapshot(cookies=[cookie("ab", ".example.com")])
    assert [r["site"] for r in sd.view(data)["sites"]] == ["example.com"]


def test_rows_do_not_depend_on_the_order_the_cookies_came_in():
    a = [cookie("ab", ".example.com"), cookie("sid", "app.example.com")]
    first = sd.view(snapshot(cookies=a))
    assert [r["site"] for r in first["sites"]] == ["app.example.com", "example.com"]
    assert first == sd.view(snapshot(cookies=list(reversed(a))))


def test_a_sites_own_dotted_cookie_is_not_shared_but_a_parents_is():
    data = snapshot(cookies=[cookie("own", ".app.example.com"), cookie("parent", ".example.com"),
                             cookie("plain", "app.example.com")], origins={APP: {"a": "1"}})
    shown = {c["name"]: c["shared"] for c in sd.site_view(data, "app.example.com")["cookies"]}
    assert shown == {"own": False, "parent": True, "plain": False}


def test_the_site_view_names_goes_and_stays_by_the_forget_rule():
    data = snapshot(cookies=[cookie("own", "app.example.com"), cookie("dot", ".app.example.com"),
                             cookie("ab", ".example.com")])
    one = sd.site_view(data, "app.example.com")
    assert one["own_cookies"] == ["own", "dot"]
    assert one["kept_shared"] == [{"name": "ab", "domain": ".example.com", "path": "/"}]
    parent = sd.site_view(data, "example.com")
    assert parent["own_cookies"] == ["ab"] and parent["kept_shared"] == []


def test_two_shared_cookies_of_one_name_stay_apart():
    data = snapshot(cookies=[cookie("sid", ".example.com"), cookie("sid", ".example.org"),
                             cookie("sid", "app.example.com")])
    assert sd.site_view(data, "app.example.com")["kept_shared"] == [
        {"name": "sid", "domain": ".example.com", "path": "/"}
    ]
    both = snapshot(cookies=[cookie("sid", ".example.com"), cookie("sid", ".example.org")],
                    origins={"https://a.example.com": {"k": "1"}, "https://a.example.org": {"k": "1"}})
    assert sd.site_view(both, "a.example.com")["kept_shared"][0]["domain"] == ".example.com"
    assert sd.site_view(both, "a.example.org")["kept_shared"][0]["domain"] == ".example.org"


def test_forget_takes_the_hosts_storage_and_own_cookies_and_keeps_the_rest():
    data = snapshot(
        cookies=[cookie("sid", "app.example.com"), cookie("dot", ".app.example.com"),
                 cookie("ab", ".example.com"), cookie("kc", "sso.example.com")],
        origins={APP: {"a": "1"}, "https://sso.example.com": {"b": "2"}},
        session={"origin": APP, "items": {"t": "1"}},
    )
    left, removed = sd.forget(data, "app.example.com")
    assert [c["name"] for c in left["cookies"]] == ["ab", "kc"]
    assert list(left["origins"]) == ["https://sso.example.com"]
    assert left["session"] == {}
    assert left["saved_at"] == NOW, "forgetting is not a save"
    assert removed == {
        "site": "app.example.com", "cookies": ["sid", "dot"], "origins": [APP],
        "kept_shared": [{"name": "ab", "domain": ".example.com", "path": "/"}],
    }


def test_forget_of_the_session_storage_host_alone_names_its_origin():
    left, removed = sd.forget(snapshot(session={"origin": "https://app.test", "items": {"t": "1"}}),
                              "app.test")
    assert left["session"] == {} and removed["origins"] == ["https://app.test"]


def test_forget_leaves_session_storage_on_another_host():
    data = snapshot(cookies=[cookie("sid", "app.test")], session={"origin": "https://sso.test", "items": {"t": "1"}})
    left, _ = sd.forget(data, "app.test")
    assert left["session"] == {"origin": "https://sso.test", "items": {"t": "1"}}


def test_forgetting_a_parent_only_row_removes_its_dotted_cookie():
    data = snapshot(cookies=[cookie("shared", ".example.com")])
    left, removed = sd.forget(data, "example.com")
    assert removed["cookies"] == ["shared"] and removed["kept_shared"] == []
    assert sd.view(left)["sites"] == []


def test_summary_is_none_when_empty():
    assert sd.summary({}) is None
    assert sd.summary(snapshot(cookies=[cookie("sid", "app.test")])) == {
        "sites": 1, "uri": "session://site-data",
    }


def test_a_jar_of_many_domains_is_not_rescanned_per_host(monkeypatch):
    """Every host used to rescan the whole jar, and every detail rebuilt the
    listing: cookies x hosts x hosts. One grouping serves all of them."""
    calls = {"n": 0}
    real = {name: getattr(sd, name) for name in ("_covers", "_own")}

    def counting(name):
        def wrapped(*a):
            calls["n"] += 1
            return real[name](*a)
        return wrapped

    for name in real:
        monkeypatch.setattr(sd, name, counting(name))
    cookies = [cookie("c", f"h{i}.example{i}.com") for i in range(500)]
    listing, details = sd.views(snapshot(cookies=cookies))
    assert len(listing["sites"]) == 500 and set(details) == {r["site"] for r in listing["sites"]}
    assert calls["n"] <= 4 * len(cookies)
    assert sd.view(snapshot(cookies=cookies)) == listing

Replace tests/test_admin_site_data.py whole:

"""The admin API over a session's snapshot: the Site data tab's routes and
the two fields each session row carries for it.

Only what the snapshot holds is listed — no secrets, which are History's —
hosts the session went to first; an httpOnly value is never shown; Forget
and Clear change the snapshot alone, never the history or the browser.
"""

import json

import pytest
from starlette.testclient import TestClient

from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.core import site_data
from kubed.selenium_flow.server import SeleniumMCP
from kubed.selenium_flow.session.store import SessionRecord

from .conftest import TOKEN

pytestmark = pytest.mark.unit

KEY = "desktop"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
DATA = {
    "cookies": [
        {"name": "sid", "value": "RAW-SECRET", "domain": "app.example.com",
         "path": "/", "http_only": True},
        {"name": "theme", "value": "dark", "domain": "app.example.com", "path": "/"},
        {"name": "shared", "value": "s", "domain": ".example.com", "path": "/"},
        {"name": "ad", "value": "1", "domain": "ads.example.net", "path": "/"},
    ],
    "origins": {"https://app.example.com": {"local": {"a": "1"}}},
    "session": {"origin": "https://app.example.com", "items": {"s": "1"}},
    "saved_at": 10.0,
}


def visited():
    return (SessionRecord(session_id="")
            .at("https://mail.example.org/", now=5.0)
            .at("https://app.example.com/x", now=6.0))


@pytest.fixture
def server():
    s = SeleniumMCP(Settings(
        grid={"url": "http://grid.invalid:4444"},
        auth={"token": TOKEN},
        secrets={"entries": {"mail": {
            "description": "the mailbox",
            "allowed_urls": ["https://mail.example.org"],
            "keys": {"user": {"value": "me"}},
        }}},
    ))
    s.sessions.store.set(KEY, visited().with_site_data(DATA))
    return s


@pytest.fixture
def client(server):
    return TestClient(server.mcp.http_app(), headers=AUTH)


def url(suffix=""):
    return f"/admin/sessions/{KEY}/site-data{suffix}"


def row(client):
    rows = client.get("/admin/sessions").json()["sessions"]
    return next(r for r in rows if r["key"] == KEY)


def test_the_list_is_the_snapshot_by_host_history_hosts_first_and_no_secrets(client):
    body = client.get(url()).json()
    assert body["key"] == KEY and body["saved_at"] == 10.0
    assert [s["site"] for s in body["sites"]] == ["app.example.com", "ads.example.net"]
    assert set(body["details"]) == {"app.example.com", "ads.example.net"}
    assert "mail.example.org" not in json.dumps(body), "a secret never makes a row here"
    assert "secrets" not in json.dumps(body)


def test_details_mask_http_only_values_and_show_both_storages(client):
    detail = client.get(url()).json()["details"]["app.example.com"]
    assert {c["name"]: c["value"] for c in detail["cookies"]} == {
        "sid": "•••", "theme": "dark", "shared": "s",
    }
    assert detail["storage"] == [{"origin": "https://app.example.com",
                                  "local_storage": {"a": "1"}, "session_storage": {"s": "1"}}]
    assert "RAW-SECRET" not in client.get(url()).text


def test_a_key_with_no_record_has_nothing_saved(client):
    assert client.get("/admin/sessions/nobody/site-data").json() == {
        "key": "nobody", "sites": [], "saved_at": None, "uri": "session://site-data",
        "details": {},
    }


def test_forget_takes_one_host_and_leaves_shared_cookies_and_the_history(client, server):
    gone = client.delete(url("/app.example.com")).json()["forgotten"]
    assert gone == {
        "site": "app.example.com", "cookies": ["sid", "theme"],
        "origins": ["https://app.example.com"],
        "kept_shared": [{"name": "shared", "domain": ".example.com", "path": "/"}],
    }
    record = server.sessions.store.get(KEY)
    assert [c["name"] for c in record.site_data["cookies"]] == ["shared", "ad"]
    assert record.site_data["origins"] == {} and record.site_data["session"] == {}
    assert record.history == visited().history, "History is not Site data's to touch"
    second = client.delete(url("/app.example.com"))
    assert second.status_code == 404
    assert second.json() == {"error": "no saved site data for app.example.com"}


def test_clear_deletes_the_snapshot_and_keeps_the_history(client, server):
    assert client.delete(url()).json() == {"cleared": ["app.example.com", "ads.example.net"]}
    record = server.sessions.store.get(KEY)
    assert record.site_data == {}
    assert record.history == visited().history
    assert client.get(url()).json()["sites"] == []
    assert client.delete(url()).json() == {"cleared": []}, "nothing left is not an error"


def test_the_routes_need_the_token(server):
    bare = TestClient(server.mcp.http_app())
    assert bare.get(url()).status_code == 401
    assert bare.delete(url("/app.example.com")).status_code == 401
    assert bare.delete(url()).status_code == 401


def test_the_row_counts_hosts_and_its_rev_moves_on_a_save_a_forget_and_a_clear(client, server):
    before = row(client)
    assert before["site_data_count"] == 2
    client.delete(url("/ads.example.net"))
    forgot = row(client)
    assert forgot["site_data_count"] == 1
    assert forgot["site_data_rev"] != before["site_data_rev"]
    record = server.sessions.store.get(KEY)
    server.sessions.store.set(KEY, record.with_site_data({**record.site_data, "saved_at": 20.0}))
    resaved = row(client)
    assert resaved["site_data_rev"] != forgot["site_data_rev"], "a re-save of the same hosts moves it"
    client.delete(url())
    assert row(client)["site_data_count"] == 0


def test_details_say_what_forget_takes_and_what_it_leaves(client):
    d = client.get(url()).json()["details"]["app.example.com"]
    assert d["own_cookies"] == ["sid", "theme"]
    assert d["kept_shared"] == [{"name": "shared", "domain": ".example.com", "path": "/"}]


def test_a_parent_only_row_can_be_forgotten_and_a_covered_host_cannot(server):
    server.sessions.store.set(KEY, visited().with_site_data({
        "cookies": [{"name": "shared", "value": "s", "domain": ".example.org", "path": "/"}],
        "origins": {}, "session": {}, "saved_at": 10.0,
    }))
    client = TestClient(server.mcp.http_app(), headers=AUTH)
    body = client.get(url()).json()
    assert [s["site"] for s in body["sites"]] == ["example.org"]
    assert body["details"]["example.org"]["own_cookies"] == ["shared"]
    assert client.delete(url("/mail.example.org")).status_code == 404, "covered, nothing of its own"
    assert client.delete(url("/example.org")).status_code == 200
    assert row(client)["site_data_count"] == 0


def test_forget_answers_404_through_the_central_policy(client):
    from kubed.selenium_flow import errors

    assert errors.status_for(errors.NotFound("x")) == 404
    body = client.delete(url("/nothing.example.net"))
    assert body.status_code == 404
    assert body.json() == {"error": "no saved site data for nothing.example.net"}


def test_the_payload_comes_from_one_grouping_of_the_jar(server, monkeypatch):
    cookies = [{"name": "c", "value": "v", "domain": f"h{i}.example{i}.com", "path": "/"}
               for i in range(300)]
    server.sessions.store.set(KEY, visited().with_site_data({
        "cookies": cookies, "origins": {}, "session": {}, "saved_at": 1.0}))
    calls = {"n": 0}
    real = site_data._Jar.covering

    def counting(self, host):
        calls["n"] += 1
        return real(self, host)

    monkeypatch.setattr(site_data._Jar, "covering", counting)
    body = TestClient(server.mcp.http_app(), headers=AUTH).get(url()).json()
    assert len(body["details"]) >= 300
    assert calls["n"] <= len(body["details"]) + 5


def test_forget_keeps_a_save_that_lands_while_it_works(client, server, monkeypatch):
    """The handler used to read the record, work, and set it back whole: a save
    landing in between was reverted. The save here runs on another thread the
    moment Forget starts computing, and must survive it."""
    import threading

    store = server.sessions.store
    real = site_data.forget
    saver = []

    def save(r):
        origins = {**r.site_data["origins"], "https://late.example.net": {"local": {"b": "2"}}}
        return r.with_site_data({**r.site_data, "origins": origins})

    def forget(data, host):
        if not saver:
            saver.append(threading.Thread(target=store.update, args=(KEY, save)))
            saver[0].start()
            saver[0].join(0.3)
        return real(data, host)

    monkeypatch.setattr(site_data, "forget", forget)
    assert client.delete(url("/app.example.com")).status_code == 200
    saver[0].join(5)
    assert list(store.get(KEY).site_data["origins"]) == ["https://late.example.net"]

Append to tests/test_site_data_surface.py:

def test_the_listing_and_one_site_are_the_declared_shapes():
    from kubed.selenium_flow.spec.schemas import SITE_DATA_SCHEMAS

    data = {
        "cookies": [{"name": "sid", "value": "1", "domain": SITE, "path": "/"}],
        "origins": {f"https://{SITE}": {"local": {"k": "v"}}},
        "session": {"origin": f"https://{SITE}", "items": {"s": "1"}},
        "saved_at": 1.0,
    }
    declared = SITE_DATA_SCHEMAS["SiteList"]["properties"]
    listing = site_data.view(data)
    assert set(listing) == set(declared)
    assert set(listing["sites"][0]) == set(declared["sites"]["items"]["properties"])
    one = site_data.site_view(data, SITE)
    assert set(one) == set(SITE_DATA_SCHEMAS["SiteData"]["properties"])

and drop the view tests that moved to tests/test_site_data_views.py:

python3 $S/drop.py tests/test_site_data.py \
  test_the_view_groups_by_host_and_shares_parent_cookies \
  test_one_host_on_two_ports_keeps_each_origins_storage_apart \
  test_a_parent_cookie_with_no_host_gets_its_own_site \
  test_secrets_are_matched_by_host_and_unleashed_ones_counted \
  test_forget_keeps_shared_cookies_and_other_sites \
  test_summary_is_none_when_empty \
  test_forgetting_a_parent_only_row_removes_its_dotted_cookie \
  test_rows_do_not_depend_on_the_order_the_cookies_came_in \
  test_saved_counts_only_what_forget_would_remove \
  test_the_site_view_names_goes_and_stays_by_the_forget_rule \
  test_a_sites_own_dotted_cookie_is_not_shared_but_a_parents_is \
  test_two_shared_cookies_of_one_name_stay_apart \
  test_a_jar_of_many_domains_is_not_rescanned_per_host

Expected: tests/test_site_data.py: dropped 13.

  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_site_data.py tests/test_site_data_surface.py Expected: FAIL — AttributeError: ... has no attribute 'history_hosts'; the listing still has saved, secrets, saved_sites; DELETE …/site-data is a 405.

  • Step 3: Implement the views — kubed/selenium_flow/core/site_data.py.

In _Jar.__init__, replace the origins loop (lines 184–186) with:

        # host -> origin -> its two storages. sessionStorage is one tab's, for
        # the one origin the save was made on; empty, it makes no row.
        self.origins: dict[str, dict] = {}
        for o, e in (self.data.get("origins") or {}).items():
            self.origins.setdefault(host_of(o), {})[o] = {
                "local": dict((e or {}).get("local") or {}), "session": {},
            }
        session = self.data.get("session") or {}
        if session.get("origin") and session.get("items"):
            entry = self.origins.setdefault(host_of(session["origin"]), {}).setdefault(
                session["origin"], {"local": {}, "session": {}}
            )
            entry["session"] = dict(session["items"])

Replace _site (lines 244–270) with:

def _site(jar: _Jar, host: str) -> dict:
    origins = jar.origins.get(host, {})
    cookies = jar.covering(host)
    # Per origin, never merged: the same host on two ports or schemes is two
    # origins, each restored with its own storage.
    storage = [
        {
            "origin": o,
            "local_storage": origins[o]["local"],
            "session_storage": origins[o]["session"],
        }
        for o in sorted(origins)
    ]
    return {
        "site": host,
        "uri": site_uri(host),
        "_cookies": cookies,
        "_own": [c for c in cookies if _own(c.get("domain") or "", host)],
        "storage": storage,
    }

Replace _rows, view, views and site_view (lines 288–351) with:

def history_hosts(history) -> list[str]:
    """The hosts of a history, most recent first, each once."""
    hosts = (host_of(v["origin"]) for v in history or ())
    return list(dict.fromkeys(h for h in hosts if h))


def _rows(data: dict, history=()) -> tuple[dict, dict]:
    """Every host the snapshot holds data for, once: ``(listing, details)``
    from a single grouping of the jar. Hosts the session went to come first,
    most recent first, then the rest alphabetically."""
    jar = _Jar(data)
    hosts = jar.hosts()
    stored = set(hosts)
    visited = [h for h in history_hosts(history) if h in stored]
    ordered = visited + [h for h in hosts if h not in set(visited)]
    rows, details = [], {}
    for host in ordered:
        site = _site(jar, host)
        rows.append({
            "site": host, "uri": site["uri"], "cookies": len(site["_cookies"]),
            "storage": [
                {
                    "origin": e["origin"],
                    "local_storage": len(e["local_storage"]),
                    "session_storage": len(e["session_storage"]),
                }
                for e in site["storage"]
            ],
        })
        details[host] = {
            "site": host, "uri": site["uri"],
            "cookies": [_cookie_view(c, host) for c in site["_cookies"]],
            "storage": site["storage"],
            # What Forget would do, by the rule Forget itself uses.
            "own_cookies": [c["name"] for c in site["_own"]],
            "kept_shared": [_identity(c) for c in _shared(site["_cookies"], host)],
        }
    listing = {"sites": rows, "saved_at": (data or {}).get("saved_at"), "uri": LIST_URI}
    return listing, details


def view(data: dict, history=()) -> dict:
    """The listing: one entry per host the snapshot holds data for, counts
    only, never a value. Every row has something saved."""
    return _rows(data, history)[0]


def views(data: dict, history=()) -> tuple[dict, dict]:
    """``(listing, details)``: the listing and every host in full, from one
    pass over the jar."""
    return _rows(data, history)


def site_view(data: dict, site: str) -> dict | None:
    """One host in full: cookies (httpOnly masked) and both storages, per
    origin."""
    return _rows(data)[1].get((site or "").lower())

Replace forget (lines 359–378) with:

def forget(data: dict, host: str) -> tuple[dict, dict]:
    """Remove one host from the snapshot: its storage, sessionStorage
    included, and the cookies named for it (``host`` or ``.host``). A
    parent-domain cookie stays — other hosts use it — and so does
    ``saved_at``: forgetting is not a save."""
    host = (host or "").lower()
    jar = _Jar(data)
    covering = jar.covering(host)
    gone_ids = {id(c) for c in covering if _own(c.get("domain") or "", host)}
    stored = data.get("origins") or {}
    origins = [o for o in stored if host_of(o) == host]
    session = data.get("session") or {}
    ours = host_of(session.get("origin") or "") == host
    if ours and session.get("items") and session["origin"] not in origins:
        origins.append(session["origin"])
    left = {
        **data,
        "cookies": [c for c in jar.cookies if id(c) not in gone_ids],
        "origins": {o: e for o, e in stored.items() if host_of(o) != host},
        **({"session": {}} if ours else {}),
    }
    return left, {
        "site": host,
        "cookies": [c["name"] for c in covering if id(c) in gone_ids],
        "origins": origins,
        "kept_shared": [_identity(c) for c in _shared(covering, host)],
    }
  • Step 4: Implement the API and the resources

kubed/selenium_flow/http/admin.py — in sessions_payload, replace the saved = site_data.view(...) line and the site_data_rev = json.dumps(...) that follows (lines 596–610) with:

            listed = site_data.view(record.site_data, record.history)
            # A save moves the time; a Forget or a Clear moves the hosts.
            site_data_rev = json.dumps(
                [record.site_data.get("saved_at"), [s["site"] for s in listed["sites"]]]
            )

and the row's "site_data_count": saved["saved_sites"], (line 629) with "site_data_count": len(listed["sites"]),.

Replace admin_site_data (lines 809–825, decorator and route included) with:

    @mcp.custom_route(
        f"{prefix}/admin/sessions/{{key}}/site-data",
        methods=["GET"],
        name="admin_site_data",
    )
    @guarded
    async def admin_site_data(request: Request) -> JSONResponse:
        """What a reopened browser gets back: the snapshot by host, each in
        full, values masked, the hosts the session went to first. No secrets:
        those are History's."""
        key = request.path_params["key"]
        try:
            record = await run_in_threadpool(sessions.store.get, key)
        except Exception as exc:  # noqa: BLE001 - errors.py says what it means
            return refused(exc, f"site data for {key}")
        data, history = (record.site_data, record.history) if record else ({}, [])
        listed, details = site_data.views(data, history)
        return JSONResponse({"key": key, **listed, "details": details})

    @mcp.custom_route(
        f"{prefix}/admin/sessions/{{key}}/site-data",
        methods=["DELETE"],
        name="admin_site_data_clear",
    )
    @guarded
    async def admin_site_data_clear(request: Request) -> JSONResponse:
        """Clear: delete the snapshot. The history and the live browser are
        untouched; only the next browser opened comes back signed out."""
        key = request.path_params["key"]
        cleared: dict = {}

        def clear(record: SessionRecord) -> SessionRecord:
            listed = site_data.view(record.site_data, record.history)
            cleared["hosts"] = [s["site"] for s in listed["sites"]]
            return record.with_site_data({})

        try:
            await run_in_threadpool(sessions.store.update, key, clear)
        except Exception as exc:  # noqa: BLE001 - errors.py says what it means
            return refused(exc, f"clearing site data for {key}")
        return JSONResponse({"cleared": cleared.get("hosts", [])})

(secret_rows is left in place for Task 7.)

kubed/selenium_flow/mcp/resources.py — replace SITE_DESCRIPTION and the three functions at the end (_saved, site_listing, one_site) with:

SITE_DESCRIPTION = (
    "What this session's last save_site_data holds: one entry per site with "
    "counts, never a value, the sites it went to first. Every browser this "
    "session opens has it back before open_session returns.\n\n"
    "session://site-data/{site} shows one site in full. Reading this never "
    "opens a browser."
)
def site_listing(sessions: SessionManager, name: str) -> dict:
    """``session://site-data``. Reads the record; never opens a browser."""
    record = sessions.store.get(name)
    if record is None:
        return site_data.view({})
    return site_data.view(record.site_data, record.history)


def one_site(sessions: SessionManager, name: str, site: str) -> dict:
    """``session://site-data/{site}``, or a ValueError that says where to look."""
    record = sessions.store.get(name)
    found = site_data.site_view(record.site_data if record else {}, site)
    if found is None:
        raise ValueError(f"no saved data for {site!r}: {site_data.LIST_URI} lists them")
    return found

kubed/selenium_flow/spec/schemas.py — replace SITE_DATA_SCHEMAS (from SITE_DATA_SCHEMAS = { to its closing } before _FILE_OPERATIONS) with:

def _storage(kind: str) -> dict:
    """localStorage and sessionStorage per origin of one host: key counts
    (``integer``) in a listing, the keys themselves (``object``) in full."""
    return {
        "type": "array",
        "description": (
            "Per origin: the same host on another port or scheme is another "
            "origin. sessionStorage is the one tab's, for the page the save was "
            "made on."
        ),
        "items": {
            "type": "object",
            "required": ["origin", "local_storage", "session_storage"],
            "properties": {
                "origin": {"type": "string"},
                "local_storage": {"type": kind},
                "session_storage": {"type": kind},
            },
        },
    }


SITE_DATA_SCHEMAS = {
    "SiteList": {
        "type": "object",
        "description": (
            "session://site-data: one entry per site the last save holds data "
            "for, counts only, the sites the session went to first."
        ),
        "properties": {
            "sites": {
                "type": "array",
                "items": {
                    "type": "object",
                    "required": ["site", "uri", "cookies", "storage"],
                    "properties": {
                        "site": {"type": "string", "description": "A host."},
                        "uri": {"type": "string"},
                        "cookies": {
                            "type": "integer",
                            "description": (
                                "Its own cookies and the parent-domain ones "
                                "that reach it."
                            ),
                        },
                        "storage": _storage("integer"),
                    },
                },
            },
            "saved_at": {
                "type": ["number", "null"],
                "description": "When the snapshot was saved, in epoch seconds.",
            },
            "uri": {"type": "string"},
        },
    },
    "SiteData": {
        "type": "object",
        "description": (
            "session://site-data/{site}: one site in full. An httpOnly cookie's "
            "value is shown as •••."
        ),
        "properties": {
            "site": {"type": "string"},
            "uri": {"type": "string"},
            "cookies": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string"},
                        "value": {"type": "string"},
                        "domain": {"type": "string"},
                        "path": {"type": "string"},
                        "expiry": {"type": ["integer", "null"]},
                        "http_only": {"type": "boolean"},
                        "secure": {"type": "boolean"},
                        "same_site": {"type": ["string", "null"]},
                        "shared": {"type": "boolean"},
                    },
                },
            },
            "storage": _storage("object"),
            "own_cookies": {
                "type": "array",
                "items": {"type": "string"},
                "description": (
                    "Cookies this site owns (host or .host): what Forget removes."
                ),
            },
            "kept_shared": {
                "type": "array",
                "items": {
                    "type": "object",
                    "required": ["name", "domain", "path"],
                    "properties": {
                        "name": {"type": "string"},
                        "domain": {"type": "string"},
                        "path": {"type": "string"},
                    },
                },
                "description": (
                    "Parent-domain cookies it only sits under: Forget keeps them."
                ),
            },
        },
    },
}
  • Step 5: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_site_data.py tests/test_site_data_surface.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_openapi.py tests/test_resources.py → PASS; then the whole suite → PASS; ruff clean.

  • Step 6: Break it on purpose — in _rows, set ordered = hosts; confirm test_hosts_in_the_history_come_first_then_the_rest_alphabetically and test_the_list_is_the_snapshot_by_host_history_hosts_first_and_no_secrets fail; restore.

  • Step 7: Commit

git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/http/admin.py \
  kubed/selenium_flow/mcp/resources.py kubed/selenium_flow/spec/schemas.py \
  tests/test_site_data_views.py tests/test_admin_site_data.py \
  tests/test_site_data_surface.py tests/test_site_data.py
git commit -m "Site data round 2: Site data is the snapshot by host, history first; Clear" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: The History view — where the session has been, by host

Files:

  • Modify: kubed/selenium_flow/core/site_data.py — add history_view after site_view; matching_secrets (230–241) is reused as is.
  • Modify: kubed/selenium_flow/session/store.py — SessionRecord.history_cleared.
  • Modify: kubed/selenium_flow/http/admin.py — the session row (beside the Task 6 fields), new admin_history and admin_history_clear (after secret_rows).
  • Create: tests/test_admin_history.py. Modify: tests/test_site_data_views.py (append).

Interfaces:

  • Consumes: Task 6's view(data, history) and history_hosts; matching_secrets(secrets, host); catalogue.listing()["secrets"] through the existing secret_rows() (entries carry name, description, keys, allowed_urls, restricted).

  • Produces:

    • site_data.history_view(history, data: dict, secrets: list[dict] | None = None) -> dict → {"sites": [row]}; row {"site": host, "url": str, "at": float, "saved": {"cookies": int, "local": int, "session": int} | None, "secrets": [{"name", "description", "keys"}]}. One row per host in history_hosts(history) order (the current one first), with the URL and time of that host's most recent entry; saved is the Site data row's counts for that host, None when it has no row; secrets join a row and never make one.
    • SessionRecord.history_cleared() -> SessionRecord — the history cut to its top entry.
    • GET {prefix}/admin/sessions/{key}/history → {"key": key, **history_view(...)}, or {"key": key, "sites": []} with no record. DELETE the same path (Clear) → {"cleared": [host, …]}: every host but the current one; site data untouched.
    • Session row: history_count = len(history_hosts(record.history)), history_rev = json.dumps([[v["origin"] for v in record.history], record.history[0]["url"] if record.history else None]) — a new site, a return to an older one, or a page within the top site moves it, so History's top row follows the session card's "last page"; only the clock moving does not. (The UI treats the rev as opaque: nothing there changes.)
  • Step 1: Write the failing tests — append to tests/test_site_data_views.py:

# ---- History: the history joined by host -----------------------------------


SECRETS = [
    {"name": "app", "description": "the app", "keys": ["user", "pass"],
     "allowed_urls": ["https://app.example.com"], "restricted": True},
    {"name": "unvisited", "description": "", "keys": ["t"],
     "allowed_urls": ["https://never.example.net"], "restricted": True},
    {"name": "anywhere", "description": "", "keys": ["t"], "allowed_urls": [],
     "restricted": False},
]


def test_the_history_is_one_row_per_host_current_first_with_counts_and_secrets():
    history = [
        visit("https://app.example.com/x", NOW - 60),
        visit("http://app.example.com:8080/dev", NOW - 120),
        visit("https://example.com/", NOW - 3600),
    ]
    data = snapshot(cookies=[cookie("sid", "app.example.com"), cookie("ab", ".example.com")],
                    origins={APP: {"a": "1", "b": "2"}})
    assert sd.history_view(history, data, SECRETS) == {"sites": [
        {"site": "app.example.com", "url": "https://app.example.com/x", "at": NOW - 60,
         "saved": {"cookies": 2, "local": 2, "session": 0},
         "secrets": [{"name": "app", "description": "the app", "keys": ["user", "pass"]}]},
        {"site": "example.com", "url": "https://example.com/", "at": NOW - 3600,
         "saved": None, "secrets": []},
    ]}


def test_a_secret_never_makes_a_row_and_nothing_visited_is_no_rows():
    data = snapshot(cookies=[cookie("t", "never.example.net")])
    assert sd.history_view([], data, SECRETS) == {"sites": []}

Create tests/test_admin_history.py:

"""The admin API over where a session has been: the History tab's routes
and the two fields each session row carries for it.

One row per host, the current one first, joined with the secrets allowed
there and what the snapshot holds for it; a secret never makes a row; Clear
keeps the current site and touches neither site data nor the browser.
"""

import json

import pytest
from starlette.testclient import TestClient

from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.server import SeleniumMCP
from kubed.selenium_flow.session.store import SessionRecord

from .conftest import TOKEN

pytestmark = pytest.mark.unit

KEY = "desktop"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
NOW = 1_790_800_000.0
SECRETS = {"entries": {
    "app": {"description": "the app", "allowed_urls": ["https://app.example.com"],
            "keys": {"user": {"value": "u"}, "pass": {"value": "p"}}},
    "mail": {"description": "the mailbox", "allowed_urls": ["https://mail.example.org"],
             "keys": {"user": {"value": "me"}}},
    "unvisited": {"description": "a site never reached",
                  "allowed_urls": ["https://never.example.net"],
                  "keys": {"token": {"value": "t"}}},
}}
DATA = {
    "cookies": [
        {"name": "sid", "value": "1", "domain": "app.example.com", "path": "/", "http_only": True},
        {"name": "theme", "value": "dark", "domain": "app.example.com", "path": "/"},
    ],
    "origins": {"https://app.example.com": {"local": {"a": "1", "b": "2"}}},
    "session": {},
    "saved_at": NOW - 30,
}


def visited():
    return (SessionRecord(session_id="")
            .at("https://mail.example.org/inbox", now=NOW - 3600)
            .at("http://app.example.com:8080/dev", now=NOW - 120)
            .at("https://app.example.com/x", now=NOW - 60))


@pytest.fixture
def server():
    s = SeleniumMCP(Settings(
        grid={"url": "http://grid.invalid:4444"}, auth={"token": TOKEN}, secrets=SECRETS,
    ))
    s.sessions.store.set(KEY, visited().with_site_data(DATA))
    return s


@pytest.fixture
def client(server):
    return TestClient(server.mcp.http_app(), headers=AUTH)


URL = f"/admin/sessions/{KEY}/history"


def row(client):
    rows = client.get("/admin/sessions").json()["sessions"]
    return next(r for r in rows if r["key"] == KEY)


def test_one_row_per_host_current_first_with_its_latest_page(client):
    body = client.get(URL).json()
    assert body["key"] == KEY
    assert [(s["site"], s["url"], s["at"]) for s in body["sites"]] == [
        ("app.example.com", "https://app.example.com/x", NOW - 60),
        ("mail.example.org", "https://mail.example.org/inbox", NOW - 3600),
    ]


def test_each_row_carries_what_is_saved_for_its_host(client):
    sites = {s["site"]: s for s in client.get(URL).json()["sites"]}
    assert sites["app.example.com"]["saved"] == {"cookies": 2, "local": 2, "session": 0}
    assert sites["mail.example.org"]["saved"] is None


def test_secrets_join_the_hosts_they_allow_and_never_make_a_row(client):
    body = client.get(URL).json()
    sites = {s["site"]: s for s in body["sites"]}
    app = sites["app.example.com"]["secrets"]
    assert [s["name"] for s in app] == ["app"]
    assert app[0]["description"] == "the app" and sorted(app[0]["keys"]) == ["pass", "user"]
    assert [s["name"] for s in sites["mail.example.org"]["secrets"]] == ["mail"]
    assert "never.example.net" not in json.dumps(body)


def test_a_key_with_no_record_has_no_rows(client):
    assert client.get("/admin/sessions/nobody/history").json() == {"key": "nobody", "sites": []}


def test_clear_keeps_the_current_site_and_nothing_else(client, server):
    assert client.delete(URL).json() == {"cleared": ["mail.example.org"]}
    record = server.sessions.store.get(KEY)
    assert record.history == visited().history[:1]
    assert record.site_data == DATA, "site data is not History's to touch"
    assert [s["site"] for s in client.get(URL).json()["sites"]] == ["app.example.com"]


def test_the_routes_need_the_token(server):
    bare = TestClient(server.mcp.http_app())
    assert bare.get(URL).status_code == 401
    assert bare.delete(URL).status_code == 401


def test_the_row_counts_hosts_and_its_rev_follows_the_origins_and_the_top_page(client, server):
    before = row(client)
    assert before["history_count"] == 2
    assert json.loads(before["history_rev"]) == [
        ["https://app.example.com", "http://app.example.com:8080", "https://mail.example.org"],
        "https://app.example.com/x",
    ]
    server.sessions.store.update(KEY, lambda r: r.at("https://mail.example.org/sent", now=NOW))
    moved = row(client)
    assert moved["history_rev"] != before["history_rev"], "another site on top"
    client.delete(URL)
    assert row(client)["history_count"] == 1


def test_a_page_within_the_top_site_moves_the_rev_and_the_clock_alone_does_not(client, server):
    """History's top row is the session card's last page: a navigation within
    the top site must repaint it, and a call that stays put must not."""
    before = row(client)["history_rev"]
    server.sessions.store.update(KEY, lambda r: r.at("https://app.example.com/y", now=NOW))
    within = row(client)["history_rev"]
    assert within != before, "a page within the top site"
    server.sessions.store.update(KEY, lambda r: r.at("https://app.example.com/y", now=NOW + 5))
    assert row(client)["history_rev"] == within, "only the clock moved"
  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_history.py Expected: FAIL — AttributeError: ... has no attribute 'history_view'; the history routes answer 404; the row has no history_count.

  • Step 3: Implement

kubed/selenium_flow/core/site_data.py, after site_view:

def history_view(history, data: dict, secrets: list[dict] | None = None) -> dict:
    """The History tab: one row per host the session landed on, the current
    one first, each with its latest URL and when, what the snapshot holds for
    it (None when nothing), and the secrets allowed there.

    Secrets join a row and never make one: a host the session never reached
    is not listed, whatever a secret allows.
    """
    saved = {r["site"]: r for r in view(data, history)["sites"]}
    latest: dict[str, dict] = {}
    for v in history or ():
        latest.setdefault(host_of(v["origin"]), v)
    rows = []
    for host in history_hosts(history):
        row = saved.get(host)
        rows.append({
            "site": host,
            "url": latest[host]["url"],
            "at": latest[host]["at"],
            "saved": {
                "cookies": row["cookies"],
                "local": sum(e["local_storage"] for e in row["storage"]),
                "session": sum(e["session_storage"] for e in row["storage"]),
            } if row else None,
            "secrets": matching_secrets(secrets, host),
        })
    return {"sites": rows}

kubed/selenium_flow/session/store.py, after with_site_data:

    def history_cleared(self) -> SessionRecord:
        """The same record with only its current page left in the history."""
        return replace(self, history=self.history[:1])

kubed/selenium_flow/http/admin.py — in sessions_payload, after the site_data_rev = … lines Task 6 wrote, add

            visited = site_data.history_hosts(record.history)

and in the row, after "site_data_rev": site_data_rev,:

                    "history_count": len(visited),
                    # The origins in order and the top page: a new site, a
                    # return to an older one, or a page within the top one
                    # repaints History, whose top row is the card's last
                    # page. Only the clock moving does not.
                    "history_rev": json.dumps([
                        [v["origin"] for v in record.history],
                        record.history[0]["url"] if record.history else None,
                    ]),

After async def secret_rows() add the two routes:

    @mcp.custom_route(
        f"{prefix}/admin/sessions/{{key}}/history",
        methods=["GET"],
        name="admin_history",
    )
    @guarded
    async def admin_history(request: Request) -> JSONResponse:
        """Where this session has been, by host, the current one first: each
        joined with the secrets allowed there and what the snapshot holds for
        it. Built per request; nothing is stored for it."""
        key = request.path_params["key"]
        try:
            secrets = await secret_rows()
            record = await run_in_threadpool(sessions.store.get, key)
        except Exception as exc:  # noqa: BLE001 - errors.py says what it means
            return refused(exc, f"history for {key}")
        if record is None:
            return JSONResponse({"key": key, "sites": []})
        joined = site_data.history_view(record.history, record.site_data, secrets)
        return JSONResponse({"key": key, **joined})

    @mcp.custom_route(
        f"{prefix}/admin/sessions/{{key}}/history",
        methods=["DELETE"],
        name="admin_history_clear",
    )
    @guarded
    async def admin_history_clear(request: Request) -> JSONResponse:
        """Clear: the history down to the current site. Site data and the live
        browser are untouched, and the history expires on its own anyway."""
        key = request.path_params["key"]
        cleared: dict = {}

        def clear(record: SessionRecord) -> SessionRecord:
            cleared["hosts"] = site_data.history_hosts(record.history)[1:]
            return record.history_cleared()

        try:
            await run_in_threadpool(sessions.store.update, key, clear)
        except Exception as exc:  # noqa: BLE001 - errors.py says what it means
            return refused(exc, f"clearing the history of {key}")
        return JSONResponse({"cleared": cleared.get("hosts", [])})
  • Step 4: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_history.py tests/test_admin_site_data.py tests/test_files_and_admin.py → PASS; the whole suite → PASS; ruff clean.

  • Step 5: Break it on purpose — in history_view, iterate saved (the Site data rows) instead of history_hosts(history); confirm test_a_secret_never_makes_a_row_and_nothing_visited_is_no_rows and test_one_row_per_host_current_first_with_its_latest_page fail; restore. Then drop the top page's URL from history_rev; confirm test_a_page_within_the_top_site_moves_the_rev_and_the_clock_alone_does_not fails; restore.

  • Step 6: Commit

git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/session/store.py \
  kubed/selenium_flow/http/admin.py tests/test_site_data_views.py tests/test_admin_history.py
git commit -m "Site data round 2: History — where the session has been, joined by host; Clear" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: The snapshot save — every origin the session has been to

Files:

  • Modify: kubed/selenium_flow/core/site_data.py — module docstring (1–17); delete merge (73–122); add LEFT_OUT, snapshot; rewrite capture (393–424).
  • Modify: kubed/selenium_flow/core/actions.py — Actions.__init__ (308–326), save_site_data (468–477).
  • Modify: kubed/selenium_flow/session/sessions.py — _save_site_data (Task 5) calls snapshot; add visited.
  • Modify: kubed/selenium_flow/server.py — wire actions.visited after self.actions.read_file = … (line 184).
  • Modify: kubed/selenium_flow/flows/run.py — run and _run (544–591) gain before_save; the step loop (the was_at setup near 606, the success branch near 788, the call near 759) feeds and calls it.
  • Modify: kubed/selenium_flow/flows/api.py — run_one (276–309) passes before_save on; run_for flushes before a save step.
  • Modify: kubed/selenium_flow/mcp/tools.py — save_site_data docstring (307–316).
  • Modify: kubed/selenium_flow/spec/schemas.py — RESPONSES["save_site_data"] (160–191).
  • Create: tests/test_site_data_save.py. Modify: tests/test_site_data.py (drop the merge and capture tests and their fakes), tests/test_site_data_surface.py, tests/test_sessions.py.

Interfaces:

  • Consumes: Task 3's spare_tab/ServiceWorkerAnswered; Task 4's READ_LOCAL, SW_REASON, _why; Task 1's SessionRecord.history; Task 5's _save_site_data.

  • Produces:

    • site_data.capture(bidi, driver, origins=()) -> dict → {"cookies": [cookie], "origin": str, "local": {}, "session": {}, "others": {origin: {local}}, "failed": [{"site", "reason"}]}. origins is the history's, newest first; the page's own origin and anything not http(s) are skipped; the rest are read one at a time in one spare tab. An unreadable jar still raises BidiUnavailable.
    • site_data.LEFT_OUT: str — the reason an origin's storage was left out over the cap.
    • site_data.snapshot(previous: dict, captured: dict, history, now: float) -> tuple[dict, dict] → (data, receipt): data is the whole new snapshot (Task 4's shape); receipt is {"cookies": int, "sites": [origin], "skipped": [{"site", "reason"}]}. A failed origin keeps previous's local storage. Over MAX_BYTES the oldest-visited origins go first, the page's own local then session last; a jar alone over it raises ValueError (400) and nothing is saved.
    • Actions.visited: Callable[[], list[str]] | None — the caller's session's history origins, newest first; None (the default) means a save reads only the page.
    • SessionManager.visited(name: str) -> list[str].
    • flows.run.run(..., before_save=None) and flows.api.run_one(..., before_save=None) — before_save(pages) is called just before a save_site_data step acts, with the pages the run has reached so far, in order: exactly the step URLs its report shows (ok steps that moved and were safe to show).
    • flows.api.run_for(...) — before a save step it writes those pages to the history in one touch, so the save reads their localStorage; the end-of-run touch then carries only the pages after the last flush. A run with no save still writes once.
    • Deleted: site_data.merge.
  • Step 1: Write the failing tests — tests/test_site_data_save.py:

"""A save: the whole jar, the page's two storages, and the localStorage of
every other origin the session has been to, read in a spare tab — one
snapshot that replaces the last (spec round 2, *save_site_data*)."""

import json
from types import SimpleNamespace

import pytest

from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.session.store import SessionRecord

from .site_data_fakes import (
    NOW,
    FakeBidi,
    FakeSpare,
    bidi_cm,
    bidi_cookie,
    cookie,
    snapshot,
)

pytestmark = pytest.mark.unit

APP, SSO, OLD = "https://app.example.com", "https://sso.example.com", "https://old.example.com"
PAGE = {"origin": APP, "local": {"a": "1"}, "session": {"t": "1"}}


def visit(origin, at=NOW):
    return {"origin": origin, "url": origin + "/", "at": at}


def captured(origin=APP, local=None, session=None, cookies=None, others=None, failed=None):
    return {
        "cookies": cookies if cookies is not None else [cookie("sid", "app.example.com")],
        "origin": origin, "local": local or {}, "session": session or {},
        "others": others or {}, "failed": failed or [],
    }


# ---- snapshot: one save replaces the last ------------------------------------


def test_a_save_replaces_the_whole_snapshot():
    before = snapshot(cookies=[cookie("old", "old.example.com")], origins={OLD: {"x": "1"}},
                      session={"origin": OLD, "items": {"s": "1"}}, saved_at=NOW - 60)
    data, saved = sd.snapshot(before, captured(local={"theme": "dark"}), [visit(APP)], NOW)
    assert data == {
        "cookies": [cookie("sid", "app.example.com")],
        "origins": {APP: {"local": {"theme": "dark"}}},
        "session": {"origin": APP, "items": {}},
        "saved_at": NOW,
    }
    assert saved == {"cookies": 1, "sites": [APP], "skipped": []}


def test_every_origin_read_is_kept_newest_first_and_an_empty_one_left_out():
    history = [visit(APP, NOW), visit(SSO, NOW - 10), visit(OLD, NOW - 20)]
    others = {OLD: {"o": "1"}, SSO: {"kc": "1"}, "https://empty.example.com": {}}
    data, saved = sd.snapshot({}, captured(local={"a": "1"}, others=others), history, NOW)
    assert list(data["origins"]) == [APP, SSO, OLD]
    assert saved["sites"] == [APP, SSO, OLD]


def test_the_page_session_storage_is_kept_for_its_origin():
    data, saved = sd.snapshot({}, captured(session={"token": "t"}), [visit(APP)], NOW)
    assert data["session"] == {"origin": APP, "items": {"token": "t"}}
    assert data["origins"] == {} and saved["sites"] == [APP], "sessionStorage alone is a site saved"


def test_a_page_with_no_origin_saves_its_cookies_and_the_others():
    data, saved = sd.snapshot({}, captured(origin="", others={SSO: {"kc": "1"}}), [visit(SSO)], NOW)
    assert data["session"] == {} and list(data["origins"]) == [SSO]
    assert saved == {"cookies": 1, "sites": [SSO], "skipped": []}


def test_an_origin_that_could_not_be_read_keeps_its_last_storage():
    before = snapshot(origins={SSO: {"kc": "old"}})
    failed = [{"site": SSO, "reason": sd.SW_REASON}, {"site": OLD, "reason": "the navigation failed"}]
    history = [visit(APP), visit(SSO), visit(OLD)]
    data, saved = sd.snapshot(before, captured(local={"a": "1"}, failed=failed), history, NOW)
    assert data["origins"] == {APP: {"local": {"a": "1"}}, SSO: {"local": {"kc": "old"}}}
    assert saved["sites"] == [APP]
    assert saved["skipped"] == failed


def test_over_the_cap_the_origins_visited_longest_ago_go_first():
    third = {"blob": "x" * (sd.MAX_BYTES // 3)}
    history = [visit(APP, NOW), visit(SSO, NOW - 10), visit(OLD, NOW - 20)]
    data, saved = sd.snapshot({}, captured(local=third, others={SSO: third, OLD: third}), history, NOW)
    assert len(json.dumps(data)) <= sd.MAX_BYTES
    assert list(data["origins"]) == [APP, SSO]
    assert saved["skipped"] == [{"site": OLD, "reason": sd.LEFT_OUT}]


def test_the_pages_own_storage_is_the_last_to_go():
    big = {"blob": "x" * (sd.MAX_BYTES - 1000)}
    history = [visit(APP), visit(SSO, NOW - 10)]
    data, saved = sd.snapshot({}, captured(local=big, session=big, others={SSO: {"kc": "1"}}),
                              history, NOW)
    assert data["origins"] == {} and data["session"]["items"] == big
    assert [s["site"] for s in saved["skipped"]] == [SSO, APP]
    assert saved["sites"] == [APP]


def test_a_cookie_jar_over_the_cap_raises_and_saves_nothing():
    big = [cookie("blob", "app.example.com", "x" * (sd.MAX_BYTES + 1))]
    with pytest.raises(ValueError, match="the cookie jar is over 1000000 bytes; nothing was saved"):
        sd.snapshot({}, captured(cookies=big), [visit(APP)], NOW)
    from kubed.selenium_flow import errors

    assert errors.status_for(ValueError("x")) == 400


def test_the_record_round_trips_its_snapshot():
    data, _ = sd.snapshot({}, captured(local={"a": "1"}, session={"t": "1"}), [visit(APP)], NOW)
    record = SessionRecord(session_id="s").at(APP + "/x").with_site_data(data)
    again = SessionRecord.from_json(record.to_json())
    assert again.site_data == data
    assert again.detached().site_data == data, "ending a browser keeps site data"
    assert again.at("https://x.example.com/").site_data == data


# ---- capture: what the browser holds -----------------------------------------


class PageDriver:
    def __init__(self, dump):
        self.dump = dump

    def execute_script(self, script, *args):
        return self.dump


@pytest.fixture
def spare(monkeypatch):
    fake = FakeSpare(local={SSO: {"kc": "1"}, OLD: {}})
    monkeypatch.setattr(sd, "spare_tab", fake)
    return fake


def test_capture_reads_the_jar_the_page_and_every_other_origin_in_one_tab(spare):
    bidi = FakeBidi()
    bidi.storage.cookies = [bidi_cookie("sid", "app.example.com", http_only=True),
                            bidi_cookie("kc", "sso.example.com")]
    got = sd.capture(bidi, PageDriver(PAGE), [APP, SSO, OLD])
    assert got["cookies"][0] == {
        "name": "sid", "value": "v", "value_type": "string", "domain": "app.example.com",
        "path": "/", "http_only": True, "secure": True, "same_site": "lax", "expiry": None,
    }
    assert (got["origin"], got["local"], got["session"]) == (APP, {"a": "1"}, {"t": "1"})
    assert got["others"] == {SSO: {"kc": "1"}, OLD: {}}
    assert got["failed"] == []
    assert spare.tabs == ["spare"], "one tab for every other origin"
    assert spare.runs == [("spare", SSO), ("spare", OLD)], "the page's own is read in place"


def test_only_web_origins_are_visited(spare):
    sd.capture(FakeBidi(), PageDriver(PAGE), ["chrome://settings", SSO, "file:///tmp"])
    assert spare.runs == [("spare", SSO)]


def test_a_service_worker_origin_is_reported_and_never_read(spare):
    spare.workers = {SSO}
    got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP, SSO, OLD])
    assert got["others"] == {OLD: {}}
    assert got["failed"] == [{"site": SSO, "reason": "a service worker answered: save while on this site"}]


def test_a_tab_that_cannot_open_fails_every_origin_it_did_not_read(spare):
    spare.broken = RuntimeError("socket is already closed")
    got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP, SSO, OLD])
    assert got["others"] == {}
    assert got["failed"] == [
        {"site": SSO, "reason": "socket is already closed"},
        {"site": OLD, "reason": "socket is already closed"},
    ]


def test_no_other_origin_means_no_tab(spare):
    got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP])
    assert spare.tabs == [] and got["others"] == {}


def test_a_save_on_a_page_with_no_storage_keeps_the_cookies(spare):
    bidi = FakeBidi()
    bidi.storage.cookies = [bidi_cookie("sid", "app.example.com")]
    got = sd.capture(bidi, PageDriver({"origin": "", "local": {}, "session": {}}))
    data, saved = sd.snapshot({}, got, [], NOW)
    assert saved == {"cookies": 1, "sites": [], "skipped": []}
    assert data["origins"] == {} and data["session"] == {}


# ---- the action, and the server that hands it the history --------------------


def page():
    return SimpleNamespace(execute_script=lambda *a: dict(PAGE), current_url=APP + "/", title="App")


def test_save_site_data_reads_the_origins_the_session_has_been_to(actions, monkeypatch, spare):
    monkeypatch.setattr(actions.grid, "reconnect", lambda sid: page())
    monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi()))
    actions.visited = lambda: [APP, SSO]
    got = actions.save_site_data("sid")
    assert got["url"] == APP + "/" and got["title"] == "App"
    assert got[sd.CAPTURED]["others"] == {SSO: {"kc": "1"}}


def test_save_site_data_without_a_history_reads_only_the_page(actions, monkeypatch, spare):
    monkeypatch.setattr(actions.grid, "reconnect", lambda sid: page())
    monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi()))
    assert actions.visited is None
    assert actions.save_site_data("sid")[sd.CAPTURED]["others"] == {}
    assert spare.tabs == []


def test_one_save_through_the_server_keeps_every_site_the_session_went_to(monkeypatch, spare):
    """The server hands `Actions` the caller's history: one save on the
    second app keeps the first's storage too (spec round 2)."""
    from starlette.testclient import TestClient

    from kubed.selenium_flow.config import Settings
    from kubed.selenium_flow.core.browser import Grid
    from kubed.selenium_flow.server import SeleniumMCP
    from kubed.selenium_flow.session.sessions import SessionManager

    from .conftest import NAMED, TOKEN

    monkeypatch.setattr(Grid, "reconnect", lambda self, sid: page())
    monkeypatch.setattr(Grid, "bidi", lambda self, sid: bidi_cm(FakeBidi())(sid))
    monkeypatch.setattr(SessionManager, "resolve", lambda self, name: "live-id")
    server = SeleniumMCP(Settings(grid={"url": "http://grid.invalid:4444"}, auth={"token": TOKEN}))
    server.sessions.store.set(
        NAMED, SessionRecord(session_id="live-id").at(SSO + "/", now=NOW - 60).at(APP + "/x", now=NOW)
    )
    response = TestClient(server.mcp.http_app()).post(
        "/browser/save-site-data", headers={"Authorization": f"Bearer {TOKEN}"},
        params={"session": NAMED}, json={},
    )
    assert response.status_code == 200, response.json()
    assert response.json()["saved"]["sites"] == [APP, SSO]
    stored = server.sessions.store.get(NAMED).site_data
    assert stored["origins"] == {APP: {"local": {"a": "1"}}, SSO: {"local": {"kc": "1"}}}
    assert stored["session"] == {"origin": APP, "items": {"t": "1"}}


# ---- a save mid-flow sees the sites the run reached before it -----------------


class Tab:
    """One browser tab: where it is, and that page's own storage."""

    title = "t"

    def __init__(self):
        self.current_url = "about:blank"

    def get(self, url):
        self.current_url = url

    def execute_script(self, script, *args):
        here = sd.origin_of(self.current_url)
        return {"origin": here, "local": {"page": here}, "session": {}}


def test_a_save_mid_flow_reads_the_sites_the_run_reached_before_it(
    named_caller, tmp_path, monkeypatch, spare
):
    """The run writes its pages once, at the end; a save step in the middle
    still has to read the sites before it, so they go in first."""
    from kubed.selenium_flow.config import Settings
    from kubed.selenium_flow.core.browser import Grid
    from kubed.selenium_flow.flows import api as flowapi
    from kubed.selenium_flow.server import SeleniumMCP

    from .conftest import NAMED

    a, b, c = "https://a.example.com", "https://b.example.com", "https://c.example.com"
    spare.local = {a: {"a": "1"}}
    tab = Tab()
    monkeypatch.setattr(Grid, "reconnect", lambda self, sid: tab)
    monkeypatch.setattr(Grid, "is_alive", lambda self, sid: True)
    monkeypatch.setattr(Grid, "bidi", lambda self, sid: bidi_cm(FakeBidi())(sid))
    server = SeleniumMCP(Settings(
        grid={"url": "http://grid.invalid:4444"}, flow={"data_dir": str(tmp_path)},
    ))
    server.flows.save(NAMED, "trip", {"steps": [
        {"tool": "navigate", "args": {"url": a + "/"}},
        {"tool": "navigate", "args": {"url": b + "/"}},
        {"tool": "save_site_data", "args": {}, "return": True},
        {"tool": "navigate", "args": {"url": c + "/"}},
    ]})
    server.sessions.store.set(NAMED, SessionRecord(session_id="live-id"))
    touched = []
    real = server.sessions.touch

    def touch(name, *urls, browser=None):
        touched.append(urls)
        return real(name, *urls, browser=browser)

    monkeypatch.setattr(server.sessions, "touch", touch)
    report = flowapi.run_for(server.flows, server.actions, server.sessions, NAMED, "trip")
    assert spare.runs == [("spare", a)], "a is read in the spare tab; b is the page"
    assert report["steps"][2]["result"]["saved"]["sites"] == [b, a]
    record = server.sessions.store.get(NAMED)
    assert record.site_data["origins"] == {b: {"local": {"page": b}}, a: {"local": {"a": "1"}}}
    assert len(touched) == 2 and touched[0] == (a + "/", b + "/"), "flushed before the save, in order"
    assert a + "/" not in touched[1] and b + "/" not in touched[1], "the end writes only what came after"
    assert [v["origin"] for v in record.history] == [c, b, a]

Drop the round 1 merge and capture tests, and the fakes only they used:

python3 $S/drop.py tests/test_site_data.py \
  captured \
  test_a_save_replaces_cookies_and_adds_its_origin \
  test_a_page_with_no_origin_saves_cookies_only \
  test_a_save_over_the_cap_keeps_cookies_and_skips_that_storage \
  test_the_record_round_trips_its_site_data \
  FakeStorage FakeScript FakeBidi bidi_cookie PageDriver bidi_cm \
  test_capture_reads_every_cookie_and_this_page_storage \
  test_save_site_data_returns_the_capture_privately \
  test_a_save_on_a_page_with_no_storage_keeps_the_cookies \
  test_the_cap_is_on_what_is_stored_and_the_oldest_other_origins_make_room \
  test_a_cookie_jar_over_the_cap_raises_and_saves_nothing

Expected: tests/test_site_data.py: dropped 16. Then replace that module's docstring with:

"""Site data's groundwork: origins as `location.origin` spells them, the
record field, the page's storage read, and the BiDi socket. A save, a
restore and the views each have their own module."""

In tests/test_site_data_surface.py, replace saved_record (it built its data with merge) with:

def saved_record():
    data = {
        "cookies": [
            {"name": "sid", "value": "s3cret", "domain": SITE, "http_only": True},
            {"name": "theme", "value": "dark", "domain": SITE},
        ],
        "origins": {f"https://{SITE}": {"local": {"k": "v"}}},
        "session": {"origin": f"https://{SITE}", "items": {}},
        "saved_at": 1000.0,
    }
    return SessionRecord(site_data=data).at(f"https://{SITE}/x")

and in test_the_published_kept_shared_is_the_shape_returned, replace the data, _ = site_data.merge(...) call with:

    data = {
        "cookies": [{"name": "ab", "value": "1", "domain": ".example.com", "path": "/",
                     "http_only": False, "secure": True, "same_site": "lax", "expiry": None}],
        "origins": {"https://app.example.com": {"local": {"k": "v"}}},
        "session": {},
        "saved_at": 0.0,
    }

In tests/test_sessions.py, test_a_losing_clean_open_erases_nothing_from_the_winner built its data with merge; replace the from kubed.selenium_flow.core import site_data import and the data, _ = site_data.merge(...) call with:

    data = {
        "cookies": [{"name": "sid", "value": "1", "domain": "app.test"}],
        "origins": {}, "session": {}, "saved_at": 1000.0,
    }
  • Step 2: Run to verify they fail

Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_save.py Expected: FAIL — AttributeError: ... has no attribute 'snapshot'; capture takes no origins; Actions has no visited; the mid-flow save reads no other site (spare.runs == []).

  • Step 3: Implement the snapshot — kubed/selenium_flow/core/site_data.py.

Replace the module docstring (lines 1–17) with:

"""Site data: the cookies and storage a session keeps for the sites it uses.

A session outlives its browser, and used to come back signed out: the page it
was on survived a reap, the sign-in did not. An agent saves when it knows the
browser is worth keeping — after a sign-in is confirmed — and every browser
opened for the session gets it back (spec 2026-09-30, rounds 1 and 2).

Saved, not captured: every call paying for a snapshot was rejected, and a reap
gives no warning, so only an explicit save is dependable.

**A save is a snapshot.** It replaces the last one whole: the jar, the
localStorage of every origin the session has been to, and the sessionStorage
of the page it is on. Other origins are reached through a spare tab
(`browser.spare_tab`), which is also how a restore writes them back before the
first page loads.

**A site is a host.** Cookies carry a domain and no scheme or port, so the view
groups by host. Storage is kept per origin, as ``location.origin`` spells it.

Values are credentials. They live in the session store and nowhere else, are
never logged, and an httpOnly cookie's value is never shown on any surface.
"""

Replace merge (from def merge( through its return data, {...}) with:

# Why an origin's storage was left out of a snapshot.
LEFT_OUT = f"left out: the snapshot would pass {MAX_BYTES} bytes"


def snapshot(previous: dict, captured: dict, history, now: float) -> tuple[dict, dict]:
    """One save, replacing the last whole: nothing is merged, so a sign-out
    saved after a sign-in is what comes back.

    ``captured`` is what :func:`capture` read. ``origins`` keeps the
    localStorage of every origin read that has any, newest first — the page,
    then the history. ``session`` is the page's sessionStorage, for its
    origin. An origin that could not be read keeps its storage from
    ``previous``, so a blip does not sign the agent out. Over
    :data:`MAX_BYTES` the origins visited longest ago go first and the page's
    own storage last; a jar that alone passes it is a ValueError, and nothing
    is saved.

    Returns ``(data, receipt)``; the receipt names what was saved, never a
    value.
    """
    here = captured.get("origin") or ""
    read = dict(captured.get("others") or {})
    if here:
        read[here] = captured.get("local") or {}
    skipped = [dict(f) for f in captured.get("failed") or []]
    last = (previous or {}).get("origins") or {}
    kept = {
        f["site"]: (last.get(f["site"]) or {}).get("local")
        for f in skipped if f.get("site")
    }
    newest = (here, *(v["origin"] for v in history or ()), *read, *kept)
    origins = {}
    for o in dict.fromkeys(o for o in newest if o):
        local = read.get(o) or kept.get(o)
        if local:
            origins[o] = {"local": dict(local)}
    items = dict(captured.get("session") or {})
    data = {
        "cookies": list(captured.get("cookies") or []),
        "origins": origins,
        "session": {"origin": here, "items": items} if here else {},
        "saved_at": now,
    }
    if _size({**data, "origins": {}, "session": {}}) > MAX_BYTES:
        # The jar is the one thing a save cannot leave out.
        raise ValueError(
            f"the cookie jar is over {MAX_BYTES} bytes; nothing was saved"
        )
    while _size(data) > MAX_BYTES:
        if data["origins"]:
            gone = list(data["origins"])[-1]
            data["origins"] = {o: e for o, e in data["origins"].items() if o != gone}
        else:
            gone, data["session"] = data["session"]["origin"], {}
        skipped.append({"site": gone, "reason": LEFT_OUT})
    sites = [o for o in data["origins"] if o in read]
    if data["session"].get("items") and here not in sites:
        sites.insert(0, here)
    return data, {"cookies": len(data["cookies"]), "sites": sites, "skipped": skipped}

Replace capture (from def capture( through its return {...}) with:

def capture(bidi, driver, origins=()) -> dict:
    """The whole cookie jar (BiDi sees httpOnly ones), the page's own two
    storages, and the localStorage of every other origin in ``origins`` — the
    history's, newest first — read one at a time in a spare tab.

    The browser answered classic WebDriver to get here, so a jar that cannot
    be read is the BiDi channel failing: :class:`BidiUnavailable`, a 503, and
    nothing is saved. An origin that cannot be read is named in ``failed``;
    the snapshot keeps its storage from the last save.
    """
    from selenium.common import WebDriverException
    from selenium.webdriver.common.bidi.storage import CookieFilter
    from websocket import WebSocketException

    try:
        jar = bidi.storage.get_cookies(CookieFilter()).cookies
    except (WebDriverException, WebSocketException, OSError) as e:
        # Only a channel that did not answer is a 503. Anything else - an
        # unexpected return shape, a bug here - stays a 500, as errors.py
        # decides for every failure it does not recognise (Copilot, #50).
        raise BidiUnavailable(BIDI_DOWN) from e
    cookies = [
        {
            "name": c.name, "value": c.value.value, "value_type": c.value.type,
            "domain": c.domain, "path": c.path, "http_only": c.http_only,
            "secure": c.secure, "same_site": c.same_site, "expiry": c.expiry,
        }
        for c in jar
    ]
    page = driver.execute_script(READ_STORAGE) or {}
    here = page.get("origin") or ""
    # Only http(s) can be stood on; the page's own origin is read in place.
    wanted = [
        o for o in dict.fromkeys(origins)
        if o != here and o.startswith(("http://", "https://"))
    ]
    others: dict[str, dict] = {}
    failed: list[dict] = []
    if wanted:
        try:
            with spare_tab(bidi) as run:
                for origin in wanted:
                    try:
                        found = run(origin, READ_LOCAL)
                        others[origin] = found if isinstance(found, dict) else {}
                    except Exception as e:  # noqa: BLE001 - one origin keeps its last storage
                        failed.append({"site": origin, "reason": _why(e)})
        except Exception as e:  # noqa: BLE001 - no tab: each origin not read keeps its last
            done = set(others) | {f["site"] for f in failed}
            failed.extend(
                {"site": o, "reason": failure_text(e)} for o in wanted if o not in done
            )
    return {
        "cookies": cookies,
        "origin": here,
        "local": page.get("local") or {},
        "session": page.get("session") or {},
        "others": others,
        "failed": failed,
    }
  • Step 4: Implement the wiring

kubed/selenium_flow/core/actions.py — at the end of Actions.__init__, after self.read_file = read_file:

        # Where the caller's session has been, newest first, so one save reads
        # every site's localStorage and not only the page's. A function for
        # the reason `keep` is one: which session is calling is not this
        # layer's to see. Absent, a save reads only the page it is on.
        self.visited = None

and replace save_site_data with:

    def save_site_data(self, session_id: str, url=None) -> dict:
        """Capture the browser's cookies, the page's storage, and the
        localStorage of every other origin the session has been to.

        The capture rides back under a private key; the session manager stores
        it and no caller sees it.
        """
        driver = self._at(session_id, url)
        origins = self.visited() if self.visited is not None else []
        with self.grid.bidi(session_id) as bidi:
            captured = site_data_module.capture(bidi, driver, origins)
        return {**browser.page_state(driver), site_data_module.CAPTURED: captured}

kubed/selenium_flow/session/sessions.py — in _save_site_data, the merge call becomes:

            data, receipt["saved"] = site_data_module.snapshot(
                r.site_data, captured, r.history, time.time()
            )

and add after context:

    def visited(self, name: str) -> list[str]:
        """The origins this session has been to, newest first."""
        record = self.store.get(name)
        return [v["origin"] for v in record.history] if record else []

kubed/selenium_flow/server.py — after the self.actions.read_file = … assignment (line 184):

        # And where the caller's session has been, so one save reads every
        # site's storage. Wired here for the same reason: which session is
        # calling is a question about the caller.
        self.actions.visited = lambda: self.sessions.visited(self.sessions.name())

kubed/selenium_flow/flows/run.py — beside OUT_OF_TIME add

# The step that reads every site in the session's history (`before_save`).
SAVE_SITE_DATA = "save_site_data"

add before_save=None, as the last parameter of run, pass it on as the last argument of the _run(...) call, and add before_save as the last parameter of _run. Add to run's docstring, after the before_step paragraph:

    ``before_save`` is called with the pages this run has reached so far, in
    order — exactly the step URLs its report shows — just before a
    ``save_site_data`` step acts. A save reads every site in the session's
    history, and the history is otherwise written once, after the run.

In _run, after was_at: str | None = None:

    # The pages the report shows, in order, for `before_save`.
    reached: list[str] = []

in the success branch, record each page the report shows:

            if went_to and went_to != was_at:
                if not redacted_url and "url" not in guarded:
                    entry["url"] = went_to
                    reached.append(went_to)
                was_at = went_to

and between before_step(dict(entry), total) and raw = method(session_id, **kwargs):

            if tool == SAVE_SITE_DATA and before_save is not None:
                before_save(list(reached))

kubed/selenium_flow/flows/api.py — run_one takes before_save=None after stop=None and passes before_save=before_save to flowrun.run(...). In run_for, insert before report = run_one(:

    # A save reads the localStorage of every site in the history, and the run
    # writes its pages once, at the end: so just before a save step the pages
    # reached so far go in, in one write, and the end writes only the rest.
    flushed = {"pages": 0, "told": None}

    def before_save(pages):
        told = sessions.touch(session, *pages[flushed["pages"]:], browser=resolved)
        flushed["pages"] = len(pages)
        flushed["told"] = flushed["told"] or told

pass before_save=before_save, to run_one(...) after before_step=before_step,, and end the function with:

    visited = [
        step["url"] for step in report.get("steps") or []
        if step.get("ok") and step.get("url")
    ][flushed["pages"]:]
    if report.get("url") and not report.get("url_redacted"):
        visited.append(report["url"])
    # A flush may already have handed over a reopen's report.
    told = sessions.touch(session, *visited, browser=resolved) or flushed["told"]
    if told:
        # The run's browser replaced a reaped one: what came back, once.
        report["site_data"] = told
    return report

kubed/selenium_flow/mcp/tools.py — the save_site_data docstring becomes (a prompt; keep the wording):

        """Save this session's site data: every cookie the browser holds, the
        localStorage of every site this session has been to, and the sessionStorage of
        the page it is on. Call it right after a sign-in is confirmed, and after
        changing a setting you want kept; never before checking you landed, or you keep
        a failed sign-in.

        One save covers every site the sign-in touched. Each save replaces the last, so
        a save after signing out saves you signed out. Every browser opened for this
        session has it back before open_session returns, the one that replaces a reaped
        browser included. Values are never returned; session://site-data lists what is
        saved.
        """

kubed/selenium_flow/spec/schemas.py — RESPONSES["save_site_data"] becomes:

    "save_site_data": _page(
        saved={
            "type": "object",
            "description": "What this save kept, in place of the last. Never a value.",
            "properties": {
                "cookies": {
                    "type": "integer",
                    "description": "Cookies saved, across every site.",
                },
                "sites": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Origins whose storage this save read and kept.",
                },
                "skipped": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "site": {"type": "string"},
                            "reason": {"type": "string"},
                        },
                        "required": ["reason"],
                    },
                    "description": (
                        "Origins not read this time, each keeping its last saved "
                        "storage, or left out over the size cap; each with why."
                    ),
                },
            },
        },
        uri={
            "type": "string",
            "description": "session://site-data, which lists what is saved.",
        },
    ),
  • Step 5: Run to verify they pass — PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_save.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_site_data_surface.py tests/test_sessions.py tests/test_surfaces.py tests/test_history.py tests/test_flowrun.py tests/test_flowapi.py tests/test_run_progress.py → PASS; the whole suite → PASS; ruff clean; grep -rn 'merge(' kubed tests --include='*.py' | grep site_data prints nothing.

  • Step 6: Break it on purpose — in snapshot, drop or kept.get(o) from local = read.get(o) or kept.get(o); confirm test_an_origin_that_could_not_be_read_keeps_its_last_storage fails; restore. Then, in server.py, comment out the visited wiring; confirm test_one_save_through_the_server_keeps_every_site_the_session_went_to fails; restore. Then, in flows/run.py, drop the before_save(list(reached)) call; confirm test_a_save_mid_flow_reads_the_sites_the_run_reached_before_it fails; restore.

  • Step 7: Commit

git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/core/actions.py \
  kubed/selenium_flow/session/sessions.py kubed/selenium_flow/server.py \
  kubed/selenium_flow/flows/run.py kubed/selenium_flow/flows/api.py \
  kubed/selenium_flow/mcp/tools.py kubed/selenium_flow/spec/schemas.py \
  tests/test_site_data_save.py tests/test_site_data.py tests/test_site_data_surface.py \
  tests/test_sessions.py
git commit -m "Site data round 2: one save is a snapshot of every site the session went to" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: The Site data tab — the snapshot, Forget and Clear

Files:

  • Modify: ui/src/lib/types.ts (SiteRow … SiteDataPayload, lines 109–140)
  • Rewrite: ui/src/admin/SiteDataPane.svelte, ui/src/admin/SiteDataPane.test.ts
  • Modify: ui/src/admin/SessionDetail.svelte (siteDataTotal, lines 245–247)

The drawing is the spec for the look: Penpot Admin UI, page Session · Site data, boards site-data, site-data-forgotten, site-data-cleared, overlay-confirm-site-forget, overlay-confirm-site-clear. Its copy, exactly:

  • above the rows: a pill saved 2m ago on the left, Clear (danger) on the right — only when something is saved;
  • a row head: its origin (the host when it has none or several), grey counts 2 cookies · 2 local · 0 session (a cookie-only host: 3 cookies), Forget (danger) on every row; the first row open, the rest shut;
  • expanded as round 1: Cookies, Local storage, Session storage (none when empty), per origin;
  • empty: Nothing saved — an agent calls save_site_data after signing in. (save_site_data in mono), no pill, no Clear;
  • Forget's confirm: title Forget site data; <origin> — a reopened browser comes back signed out here.; goes <names · …>, then one stays <name>, shared with <domain> line per kept shared cookie; buttons Cancel / Forget;
  • Clear's confirm: title Clear site data; 3 sites — a reopened browser comes back signed out.; goes <host · host · …>; buttons Cancel / Clear.
  • Gone from round 1: secrets on this tab, Nothing saved. It stays listed because a secret is allowed here., nothing saved.
  • The tab counts the hosts listed. Keep #paneSiteData section.section, .title and code.key: the integration flow reads them.

Interfaces:

  • Consumes: GET /admin/sessions/{key}/site-data → {key, sites: [{site, uri, cookies, storage: [{origin, local_storage, session_storage}]}], saved_at, uri, details: {host: {site, uri, cookies: [cookie], storage: [{origin, local_storage: {}, session_storage: {}}], own_cookies, kept_shared}}}; DELETE …/site-data/{site}; DELETE …/site-data (Task 6).

  • Produces: SiteRow, SiteDetail, SiteDataPayload as below (Task 10 reads SiteRow); SiteDataPane props unchanged.

  • Step 1: Write the failing tests — replace ui/src/admin/SiteDataPane.test.ts whole:

import { fireEvent, render, screen, within } from '@testing-library/svelte'
import { afterEach, expect, test, vi } from 'vitest'
import { deferred, fakeFetch } from '../test/helpers'
import { createApi } from './api'
import { resetFolds } from './folds.svelte'
import { Live } from './live.svelte'
import SessionDetail from './SessionDetail.svelte'

const api = createApi({ base: '', token: () => 't', onUnauthorized: () => {} })
afterEach(() => resetFolds())

const cookie = (name: string, value: string, extra = {}) =>
  ({ name, value, domain: 'the-internet.herokuapp.com', path: '/', expiry: null, http_only: false, secure: false, same_site: null, shared: false, ...extra })
const ORIGIN = 'https://the-internet.herokuapp.com'
const GRAFANA = 'https://grafana.example.com'
const detail = {
  site: 'the-internet.herokuapp.com', uri: 'session://site-data/the-internet.herokuapp.com',
  cookies: [
    cookie('rack.session', '•••', { http_only: true, secure: true }),
    cookie('optimizelyEndUserId', 'oeu1', { domain: '.herokuapp.com', expiry: 1790800000, shared: true }),
  ],
  storage: [{ origin: ORIGIN, local_storage: { theme: 'dark', 'tour-seen': 'true' } as Record<string, string>, session_storage: {} as Record<string, string> }],
  own_cookies: ['rack.session'], kept_shared: [{ name: 'optimizelyEndUserId', domain: '.herokuapp.com', path: '/' }],
}
const counted = (origin: string, local: number, session = 0) => ({ origin, local_storage: local, session_storage: session })
const SITES = {
  key: 'k', saved_at: Date.now() / 1000 - 120, uri: 'session://site-data',
  sites: [
    { site: 'the-internet.herokuapp.com', uri: '', cookies: 2, storage: [counted(ORIGIN, 2)] },
    { site: 'grafana.example.com', uri: '', cookies: 5, storage: [counted(GRAFANA, 12)] },
    { site: 'keycloak.example.com', uri: '', cookies: 3, storage: [] },
  ],
  details: {
    'the-internet.herokuapp.com': detail,
    'grafana.example.com': { ...detail, site: 'grafana.example.com', cookies: [], storage: [{ origin: GRAFANA, local_storage: {}, session_storage: {} }], own_cookies: [], kept_shared: [] },
    'keycloak.example.com': { site: 'keycloak.example.com', uri: '', cookies: [cookie('KC', 'k', { domain: 'keycloak.example.com' })], storage: [], own_cookies: ['KC'], kept_shared: [] },
  },
}
const row = { key: 'k', name: 'mine', live: true, attached: true, session_id: 'b1', files_rev: 1, flows_rev: 1, files_count: 0, site_data_count: 3, site_data_rev: 'r1' }
const base = {
  'GET /admin/sessions/k/files': { body: { session: row, downloads: [], screenshots: [], files: [], browser: true } },
  'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1, session: 'k' } },
}

function setup(routes = {}) {
  const net = fakeFetch({ ...base, 'GET /admin/sessions/k/site-data': { body: SITES }, ...routes })
  const live = new Live(api, '')
  const r = render(SessionDetail, { key: 'k', tab: 'site-data', flow: undefined, api, live, root: '' })
  return { ...r, ...net, live }
}
const sections = (c: HTMLElement) => [...c.querySelectorAll('#paneSiteData section.section')] as HTMLElement[]
const loaded = (c: HTMLElement, n = 3) => vi.waitFor(() => expect(sections(c)).toHaveLength(n))
const modal = () => document.querySelector('.modal') as HTMLElement
const pairs = () => [...modal().querySelectorAll('.pair')].map((p) => p.textContent)

test('the subtab counts the hosts, and rows come in order with their counts', async () => {
  const { container } = setup()
  expect(screen.getByRole('tab', { name: /Site data/ })).toHaveAttribute('aria-selected', 'true')
  await loaded(container)
  expect(container.querySelector('#siteDataTotal')).toHaveTextContent('3')
  const [a, b, c] = sections(container)
  expect(a.querySelector('.title')).toHaveTextContent(ORIGIN)
  expect(a.querySelector('.head')).toHaveTextContent('2 cookies · 2 local · 0 session')
  expect(b.querySelector('.head')).toHaveTextContent('5 cookies · 12 local · 0 session')
  expect(c.querySelector('.title')).toHaveTextContent('keycloak.example.com')
  expect(c.querySelector('.head')).toHaveTextContent('3 cookies')
  expect(c.querySelector('.head')).not.toHaveTextContent('local')
  expect(a.querySelector('.head .pill')).toBeNull()
})

test('one saved pill and Clear sit above the rows', async () => {
  const { container } = setup()
  await loaded(container)
  const bar = container.querySelector('#paneSiteData .bar') as HTMLElement
  expect(bar.querySelector('.pill')).toHaveTextContent('saved 2m ago')
  expect(within(bar).getByRole('button', { name: 'Clear' })).toHaveClass('danger')
})

test('the first row is open, the others shut', async () => {
  const { container } = setup()
  await loaded(container)
  const [a, b, c] = sections(container)
  expect(a.querySelector('.title')).toHaveAttribute('aria-expanded', 'true')
  expect(b.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
  expect(c.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
})

test('expanded: cookies with dots for httpOnly, flags, expiry; storage; none', async () => {
  const { container } = setup()
  await loaded(container)
  const a = within(sections(container)[0])
  const rack = a.getByText('rack.session').closest('.line') as HTMLElement
  expect(rack).toHaveTextContent('•••')
  expect(rack).toHaveTextContent('the-internet.herokuapp.com · session')
  expect([...rack.querySelectorAll('.pill')].map((p) => p.textContent)).toEqual(['httpOnly', 'secure'])
  const opt = a.getByText('optimizelyEndUserId').closest('.line') as HTMLElement
  expect(opt).toHaveTextContent(new Date(1790800000 * 1000).toLocaleDateString())
  expect(opt.querySelector('.pill.shared')).toHaveTextContent('shared')
  expect(a.getByText('theme').closest('.line')).toHaveTextContent('dark')
  // One line each, the whole value on hover: a long cookie wrapped to seven.
  const value = opt.querySelector('.value') as HTMLElement
  expect(value).toHaveClass('clip')
  expect(value).toHaveAttribute('title', value.textContent!)
  expect(a.getByText('Session storage').nextElementSibling!).toHaveTextContent('none')
})

test('the shared pill follows the payload: a site\'s own dotted cookie has none', async () => {
  const own = cookie('own', 'o', { domain: '.the-internet.herokuapp.com', shared: false })
  const d = { ...detail, cookies: [own, ...detail.cookies.slice(1)] }
  const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: { ...SITES, details: { ...SITES.details, [detail.site]: d } } } })
  await loaded(container)
  const a = within(sections(container)[0])
  expect(a.getByText('own').closest('.line')!.querySelector('.pill.shared')).toBeNull()
  expect(a.getByText('optimizelyEndUserId').closest('.line')!.querySelector('.pill.shared')).not.toBeNull()
})

test('every row has Forget, and the tab shows no secrets and no "nothing saved"', async () => {
  const { container } = setup()
  await loaded(container)
  for (const s of sections(container)) expect(within(s).getByRole('button', { name: 'Forget' })).toBeInTheDocument()
  await fireEvent.click(sections(container)[2].querySelector('.title')!)
  const pane = container.querySelector('#paneSiteData') as HTMLElement
  expect(pane.querySelector('.line.secret')).toBeNull()
  expect(pane).not.toHaveTextContent('nothing saved')
  expect(pane).not.toHaveTextContent('It stays listed because a secret is allowed here.')
})

test('nothing saved: the drawn line, no pill, no Clear, a count of 0', async () => {
  const none = { key: 'k', saved_at: null, uri: 'session://site-data', sites: [], details: {} }
  const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: none } })
  await vi.waitFor(() => expect(container.querySelector('#paneSiteData > p')).not.toBeNull())
  const line = container.querySelector('#paneSiteData > p')!
  expect(line).toHaveTextContent('Nothing saved — an agent calls save_site_data after signing in.')
  expect(line.querySelector('code')).toHaveTextContent('save_site_data')
  expect(container.querySelector('#paneSiteData .bar')).toBeNull()
  expect(container.querySelector('#siteDataTotal')).toHaveTextContent('0')
})

test('Forget confirms with goes and stays, then DELETEs the site and reloads', async () => {
  const { container, calls } = setup({ 'DELETE /admin/sessions/k/site-data/the-internet.herokuapp.com': { body: { forgotten: {} } } })
  await loaded(container)
  await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
  expect(modal().querySelector('.head')).toHaveTextContent('Forget site data')
  expect(modal()).toHaveTextContent(ORIGIN + ' — a reopened browser comes back signed out here.')
  expect(pairs()).toEqual([
    'goesrack.session · theme · tour-seen',
    'staysoptimizelyEndUserId, shared with .herokuapp.com',
  ])
  expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(0)
  const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
  const before = gets()
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Forget' }))
  await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/site-data/the-internet.herokuapp.com']))
  await vi.waitFor(() => expect(gets()).toBe(before + 1))
})

test('Clear confirms with the hosts, then DELETEs the snapshot and reloads', async () => {
  const { container, calls } = setup({ 'DELETE /admin/sessions/k/site-data': { body: { cleared: [] } } })
  await loaded(container)
  await fireEvent.click(within(container.querySelector('#paneSiteData .bar') as HTMLElement).getByRole('button', { name: 'Clear' }))
  expect(modal().querySelector('.head')).toHaveTextContent('Clear site data')
  expect(modal()).toHaveTextContent('3 sites — a reopened browser comes back signed out.')
  expect(pairs()).toEqual(['goesthe-internet.herokuapp.com · grafana.example.com · keycloak.example.com'])
  const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
  const before = gets()
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Clear' }))
  await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/site-data']))
  await vi.waitFor(() => expect(gets()).toBe(before + 1))
})

test('Cancel forgets and clears nothing', async () => {
  const { container, calls } = setup()
  await loaded(container)
  await fireEvent.click(within(sections(container)[1]).getByRole('button', { name: 'Forget' }))
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Cancel' }))
  await fireEvent.click(within(container.querySelector('#paneSiteData .bar') as HTMLElement).getByRole('button', { name: 'Clear' }))
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Cancel' }))
  expect(calls.some((c) => c.method === 'DELETE')).toBe(false)
})

test('a pushed site_data_rev change reloads; the same rev does not', async () => {
  const { container, calls, live } = setup()
  await loaded(container)
  const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
  expect(gets()).toBe(1)
  const push = (rev: string) => { live.data = { sessions: [{ ...row, site_data_rev: rev }] } }
  push('r1')
  await new Promise((r) => setTimeout(r, 10))
  expect(gets()).toBe(1)
  push('r2')
  await vi.waitFor(() => expect(gets()).toBe(2))
})

test('a failed load is retried by the next push at the same rev', async () => {
  let n = 0
  const { container, live } = setup({
    'GET /admin/sessions/k/site-data': () => (++n <= 2 ? { status: 500, body: { error: 'flaky' } } : { body: SITES }),
  })
  await vi.waitFor(() => expect(screen.getByText('flaky')).toBeInTheDocument())
  live.data = { sessions: [{ ...row, site_data_rev: 'r1' }] }
  await vi.waitFor(() => expect(n).toBe(2))
  await new Promise((r) => setTimeout(r, 10))
  live.data = { sessions: [{ ...row, site_data_rev: 'r1', files_count: 1 }] }
  await loaded(container)
  expect(n).toBe(3)
})

test('an error shows in the pane, Loading… before', async () => {
  const d = deferred<{ status: number; body: unknown }>()
  const { container } = setup({ 'GET /admin/sessions/k/site-data': () => d.promise })
  expect(within(container.querySelector('#paneSiteData') as HTMLElement).getByText('Loading…')).toBeInTheDocument()
  d.resolve({ status: 500, body: { error: 'nope' } })
  await vi.waitFor(() => expect(screen.getByText('nope')).toHaveClass('error'))
})

test('a parent-only row: its dotted cookie goes', async () => {
  const PARENT = cookie('shared', 's', { domain: '.example.com', shared: false })
  const data = {
    key: 'k', saved_at: Date.now() / 1000 - 60, uri: '',
    sites: [{ site: 'example.com', uri: '', cookies: 1, storage: [] }],
    details: { 'example.com': { site: 'example.com', uri: '', cookies: [PARENT], storage: [], own_cookies: ['shared'], kept_shared: [] } },
  }
  const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
  await loaded(container, 1)
  await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
  expect(pairs()).toEqual(['goesshared'])
})

test('after Forget the tab counts the reloaded payload, before any push', async () => {
  let n = 0
  const after = { ...SITES, sites: SITES.sites.slice(1) }
  const { container } = setup({
    'GET /admin/sessions/k/site-data': () => ({ body: ++n === 1 ? SITES : after }),
    'DELETE /admin/sessions/k/site-data/the-internet.herokuapp.com': { body: { forgotten: {} } },
  })
  await loaded(container)
  expect(container.querySelector('#siteDataTotal')).toHaveTextContent('3')
  await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Forget' }))
  await loaded(container, 2)
  expect(container.querySelector('#siteDataTotal')).toHaveTextContent('2')
})

test('every id in the pane is unique, and each fold controls its own body', async () => {
  const { container } = setup()
  await loaded(container)
  const ids = [...container.querySelectorAll('#paneSiteData [id]')].map((e) => e.id)
  expect(new Set(ids).size).toBe(ids.length)
  for (const s of sections(container)) {
    const body = s.querySelector('.title')!.getAttribute('aria-controls')!
    expect(body).not.toBe(s.id)
    expect(s.querySelector('.body')!.id).toBe(body)
  }
})

test('two shared cookies of one name both stay, each under its own domain', async () => {
  const sid = (domain: string, path = '/') => ({ name: 'sid', domain, path })
  const data = {
    key: 'k', saved_at: Date.now() / 1000, uri: '',
    sites: [{ site: 'app.example.com', uri: '', cookies: 2, storage: [counted('https://app.example.com', 1)] }],
    details: {
      'app.example.com': {
        site: 'app.example.com', uri: '', cookies: [],
        storage: [{ origin: 'https://app.example.com', local_storage: { a: '1' }, session_storage: {} }],
        own_cookies: [], kept_shared: [sid('.example.com'), sid('.example.org', '/app')],
      },
    },
  }
  const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
  await loaded(container, 1)
  await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
  expect(pairs()).toEqual([
    'goesa',
    'stayssid, shared with .example.com',
    'stayssid, shared with .example.org /app',
  ])
})

test('one host on two ports: a storage group per origin, each labelled, the host as the title', async () => {
  const dev = (port: number, local: Record<string, string>) => ({ origin: 'http://localhost:' + port, local_storage: local, session_storage: {} })
  const data = {
    key: 'k', saved_at: Date.now() / 1000, uri: '',
    sites: [{ site: 'localhost', uri: '', cookies: 0, storage: [counted('http://localhost:3000', 1), counted('http://localhost:8080', 1)] }],
    details: { localhost: { site: 'localhost', uri: '', cookies: [], own_cookies: [], kept_shared: [], storage: [dev(3000, { k: 'a' }), dev(8080, { k: 'b' })] } },
  }
  const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
  await loaded(container, 1)
  const [s] = sections(container)
  expect(s.querySelector('.title')).toHaveTextContent('localhost')
  expect(s.querySelector('.head')).toHaveTextContent('0 cookies · 2 local · 0 session')
  expect([...s.querySelectorAll('h3')].map((h) => h.textContent)).toEqual([
    'Cookies',
    'Local storage · http://localhost:3000', 'Session storage · http://localhost:3000',
    'Local storage · http://localhost:8080', 'Session storage · http://localhost:8080',
  ])
  const values = [...s.querySelectorAll('.line')].filter((l) => l.querySelector('code.key')?.textContent === 'k').map((l) => l.querySelector('.value')!.textContent)
  expect(values).toEqual(['a', 'b'])
})
  • Step 2: Run to verify they fail

Run: cd ui && npx vitest run src/admin/SiteDataPane.test.ts Expected: FAIL — no .bar, the counts still end in · N secret(s), the cookie-only row reads nothing saved…, no Clear.

  • Step 3: Implement

ui/src/lib/types.ts — replace from /** One origin of a host… (line 109) through SiteDataPayload (line 140) with:

/** One origin of a host: the same host on another port or scheme is another. */
export interface SiteStorage<T = Record<string, string>> {
  origin: string
  local_storage: T
  session_storage: T
}
/** A host the snapshot holds data for: counts only. */
export interface SiteRow {
  site: string
  uri?: string
  cookies: number
  storage: SiteStorage<number>[]
}
/** A parent-domain cookie Forget leaves, named whole: two can share a name. */
export interface SharedCookie { name: string; domain: string; path: string }
export interface SiteDetail extends Omit<SiteRow, 'cookies' | 'storage'> {
  cookies: SiteCookie[]
  storage: SiteStorage[]
  /** What Forget removes and what it leaves, by the server's own rule. */
  own_cookies: string[]
  kept_shared: SharedCookie[]
}
/** What a reopened browser gets back: the last save, by host. */
export interface SiteDataPayload {
  key: string
  sites: SiteRow[]
  saved_at: number | null
  uri?: string
  details: Record<string, SiteDetail>
}

(SiteSecret above it stays: Task 10's History uses it.)

ui/src/admin/SiteDataPane.svelte — replace whole:

<script lang="ts">
  import { untrack } from 'svelte'
  import { ago } from '../lib/format'
  import type { SiteCookie, SiteDetail, SiteRow, SiteStorage } from '../lib/types'
  import { sessionPath, type Api } from './api'
  import { folds } from './folds.svelte'
  import type { ModalSpec } from './modal'
  import Section from './Section.svelte'
  import type { SessionModel } from './session.svelte'

  let { m, api, ask, refuseIfGone, isGone, hidden }: {
    m: SessionModel
    api: Api
    ask: <T>(spec: ModalSpec<T>) => void
    refuseIfGone: () => void
    isGone: () => boolean
    hidden: boolean
  } = $props()

  const data = $derived(m.siteData)
  // Ends in `Section`: Section derives its body's id by replacing that.
  const idOf = (r: SiteRow) => 'site-' + m.key + ':' + r.site + 'Section'

  // The first row opens, the rest stay shut — once; after that the fold is
  // the reader's.
  $effect.pre(() => {
    const sites = data?.sites ?? []
    untrack(() => sites.forEach((r, i) => { folds[idOf(r)] ??= i === 0 }))
  })

  const plural = (n: number, one: string, many = one + 's') => n + ' ' + (n === 1 ? one : many)
  const sum = (r: SiteRow, k: 'local_storage' | 'session_storage') => r.storage.reduce((n, e) => n + e[k], 0)
  // A cookie-only host reads "3 cookies"; one with storage gives every count.
  const counts = (r: SiteRow) => [
    plural(r.cookies, 'cookie'),
    ...(r.storage.length ? [sum(r, 'local_storage') + ' local', sum(r, 'session_storage') + ' session'] : []),
  ].join(' · ')
  // The origin when the host has one, the host when it has none or several.
  const titleOf = (r: SiteRow) => (r.storage.length === 1 ? r.storage[0].origin : r.site)

  const keysOf = (d: SiteDetail | undefined) =>
    [...new Set((d?.storage ?? []).flatMap((e) => [...Object.keys(e.local_storage), ...Object.keys(e.session_storage)]))]
  // A host with no storage still shows both groups, as "none".
  const groups = (d: SiteDetail): SiteStorage[] =>
    d.storage.length ? d.storage : [{ origin: '', local_storage: {}, session_storage: {} }]

  const expires = (c: SiteCookie) => (c.expiry ? new Date(c.expiry * 1000).toLocaleDateString() : 'session')

  interface Forget { origin: string; goes: string[]; stays: { key: string; label: string }[] }

  function forget(r: SiteRow) {
    const d: SiteDetail | undefined = data?.details[r.site]
    // Which cookies go, and which stay, is the server's call (Forget's own
    // rule); each staying cookie arrives with its own domain and path.
    ask<Forget>({
      title: 'Forget site data',
      body: forgetBody,
      data: {
        origin: titleOf(r),
        goes: [...(d?.own_cookies ?? []), ...keysOf(d)],
        stays: (d?.kept_shared ?? []).map((c) => ({
          key: [c.name, c.domain, c.path].join('|'),
          label: c.name + ', shared with ' + c.domain + (c.path && c.path !== '/' ? ' ' + c.path : ''),
        })),
      },
      confirm: 'Forget', danger: true,
      onconfirm: async () => {
        refuseIfGone()
        await api(sessionPath(m.key, '/site-data/' + encodeURIComponent(r.site)), 'DELETE')
        if (!isGone()) void m.loadSiteData()
      },
    })
  }

  function clear() {
    ask<string[]>({
      title: 'Clear site data',
      body: clearBody,
      data: (data?.sites ?? []).map((r) => r.site),
      confirm: 'Clear', danger: true,
      onconfirm: async () => {
        refuseIfGone()
        await api(sessionPath(m.key, '/site-data'), 'DELETE')
        if (!isGone()) void m.loadSiteData()
      },
    })
  }
</script>

{#snippet forgetBody(f: Forget)}
  <p>{f.origin} — a reopened browser comes back signed out here.</p>
  <div class="pairs fate-list">
    {#if f.goes.length}<div class="pair"><span class="pk">goes</span><span class="pv">{f.goes.join(' · ')}</span></div>{/if}
    {#each f.stays as s (s.key)}<div class="pair"><span class="pk">stays</span><span class="pv">{s.label}</span></div>{/each}
  </div>
{/snippet}

{#snippet clearBody(hosts: string[])}
  <p>{plural(hosts.length, 'site')} — a reopened browser comes back signed out.</p>
  <div class="pairs fate-list">
    <div class="pair"><span class="pk">goes</span><span class="pv">{hosts.join(' · ')}</span></div>
  </div>
{/snippet}

<!-- Which origin a group is, only when the host has more than one. -->
{#snippet from(d: SiteDetail, e: SiteStorage)}
  {#if d.storage.length > 1}<span class="origin">{' · ' + e.origin}</span>{/if}
{/snippet}

{#snippet kv(entries: [string, string][])}
  {#each entries as [k, v] (k)}
    <div class="line"><code class="key">{k}</code><span class="value clip" title={v}>{v}</span></div>
  {:else}
    <div class="line"><span class="value muted">none</span></div>
  {/each}
{/snippet}

<div id="paneSiteData" {hidden}>
  {#if m.siteDataError}
    <div class="empty error">{m.siteDataError}</div>
  {:else if !data}
    <div class="empty">Loading…</div>
  {:else if !data.sites.length}
    <p class="small muted">Nothing saved — an agent calls <code>save_site_data</code> after signing in.</p>
  {:else}
    <div class="row bar">
      {#if data.saved_at}<span class="pill">saved {ago(data.saved_at * 1000)}</span>{/if}
      <span class="grow"></span>
      <button id="clearSiteData" class="danger" onclick={clear}>Clear</button>
    </div>
    {#each data.sites as r (r.site)}
      {@const d = data.details[r.site]}
      <Section id={idOf(r)} title={titleOf(r)}>
        {#snippet summary()}<span class="small muted">{counts(r)}</span>{/snippet}
        {#snippet actions()}<button class="danger" onclick={() => forget(r)}>Forget</button>{/snippet}
        {#if d}
          <h3>Cookies</h3>
          {#each d.cookies as c (c.name + c.domain + c.path)}
            <div class="line cookie">
              <code class="key">{c.name}</code>
              <span class="value clip" title={c.value}>{c.value}</span>
              <span class="small muted">{c.domain} · {expires(c)}</span>
              <span class="flags">
                {#if c.http_only}<span class="pill">httpOnly</span>{/if}
                {#if c.secure}<span class="pill">secure</span>{/if}
                {#if c.shared}<span class="pill shared">shared</span>{/if}
              </span>
            </div>
          {:else}
            <div class="line"><span class="value muted">none</span></div>
          {/each}
          {#each groups(d) as e (e.origin)}
            <h3>Local storage{@render from(d, e)}</h3>
            {@render kv(Object.entries(e.local_storage))}
            <h3>Session storage{@render from(d, e)}</h3>
            {@render kv(Object.entries(e.session_storage))}
          {/each}
        {/if}
      </Section>
    {/each}
  {/if}
</div>

<style>
  .bar { margin-bottom: 12px; }
  h3 { margin: 14px 0 4px; font-size: 11px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
  .line { display: grid; grid-template-columns: 240px 1fr auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); }
  .line.cookie { grid-template-columns: 240px 1fr auto auto; }
  h3 .origin { text-transform: none; letter-spacing: 0; }
  .key { font-size: 12px; overflow-wrap: anywhere; }
  .value { overflow-wrap: anywhere; min-width: 0; }
  /* One line per entry; the whole value is on hover. A pending-events cookie
     wrapped to seven lines and pushed its neighbours apart. */
  .value.clip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .flags { display: inline-flex; gap: 4px; justify-content: flex-end; }
  .pill.shared { color: var(--accent); border-color: currentColor; }
  .fate-list { padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--ink) 5%, transparent); }
  .fate-list .pk { width: 48px; }
</style>

ui/src/admin/SessionDetail.svelte — replace the siteDataTotal comment and line (245–247) with:

  // The hosts listed, from the loaded payload rather than the last pushed
  // row: a Forget reloads the pane before the next push brings the row up.
  const siteDataTotal = $derived(m.siteDataError || !m.siteData ? '' : String(m.siteData.sites.length))
  • Step 4: Run to verify they pass — cd ui && npx vitest run && npm run -s check && npm run -s lint → PASS, 0 errors, 0 warnings; npm --prefix ui run build && npm --prefix ui run size succeeds — the admin surface within its 45 KB gzipped budget (output is gitignored).

  • Step 5: Break it on purpose — in counts, drop the r.storage.length ? condition (always add local and session); confirm the subtab counts the hosts, and rows come in order with their counts fails on the cookie-only row; restore.

  • Step 6: Commit

git add ui/src/lib/types.ts ui/src/admin/SiteDataPane.svelte ui/src/admin/SiteDataPane.test.ts \
  ui/src/admin/SessionDetail.svelte
git commit -m "Site data round 2: the Site data tab is the snapshot — one saved time, Forget and Clear" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: The History tab

Files:

  • Create: ui/src/admin/HistoryPane.svelte, ui/src/admin/HistoryPane.test.ts
  • Modify: ui/src/lib/types.ts (SessionRow, lines 21–40; new SavedCounts, HistoryRow, HistoryPayload)
  • Modify: ui/src/admin/router.svelte.ts (whole file, 59 lines), ui/src/admin/router.test.ts
  • Modify: ui/src/admin/session.svelte.ts (fields 48–63, #showRow 188–196, dispose 199–205; new loadHistory), ui/src/admin/session.test.ts
  • Modify: ui/src/admin/SessionDetail.svelte (imports, props 16–23, totals 244–248, tabs 284–288, panes 300), ui/src/admin/Admin.svelte (line 101)
  • Modify: ui/src/admin/SiteDataPane.svelte (the site prop: open and scroll to a linked host), ui/src/admin/SiteDataPane.test.ts (one base route)

The drawing is the spec for the look: Penpot Admin UI, page Session · History, boards history, history-cleared, overlay-confirm-history-clear. Its copy, exactly:

  • the subtabs: Files · Flows · Site data · History, each with its count; History counts the hosts listed;
  • above the rows, on the right, Clear (danger) — only while there is more than the current site;
  • one collapsible row per host, folding like Site data's rows (Dr K: consistent with Site data): the current site on top, then most recent first; the top row opens, the rest stay shut — once, after that the fold is the reader's; no marker on the top row. Its head: the caret, the latest URL in bold, 1m ago · 1 secret in grey (the secret count left out when there are none), and a pill with the saved counts for that host, zero counts left out (2 cookies · 2 local), which opens that host in Site data; no pill when nothing is saved there;
  • opened, one line per secret allowed on that host: 🔑 name, its description, one pill per key — the Secrets tab's line. A row with no secrets has nothing to open: no caret (its place kept), not a button;
  • Clear's confirm: title Clear history; History only: site data and the browser are untouched.; goes <host · host · …>; stays <current host>, the current site; buttons Cancel / Clear.
  • Not drawn: a session that has been nowhere. Use the session card's own words, Nowhere yet., as the pane's empty line.

Interfaces:

  • Consumes: GET /admin/sessions/{key}/history → {key, sites: [{site, url, at, saved: {cookies, local, session} | null, secrets: [{name, description, keys}]}]}; DELETE the same; the session row's history_count, history_rev (Task 7).

  • Produces:

    • router: type Tab = 'files' | 'flows' | 'site-data' | 'history'; a session route carries site: string | undefined (the fourth hash segment on Site data) beside flow (now only on Flows); hashes.history(key), hashes.site(key, host) → #/sessions/<key>/site-data/<host>.
    • SessionModel.history: HistoryPayload | null, historyError, loadHistory(); a pushed row reloads it when history_rev moves.
    • SessionDetail takes site?: string and passes it to SiteDataPane, which opens that host's row and scrolls to it.
  • Step 1: Write the failing tests

ui/src/admin/HistoryPane.test.ts:

import { fireEvent, render, screen, within } from '@testing-library/svelte'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { deferred, fakeFetch } from '../test/helpers'
import { createApi } from './api'
import { resetFolds } from './folds.svelte'
import { Live } from './live.svelte'
import SessionDetail from './SessionDetail.svelte'

const api = createApi({ base: '', token: () => 't', onUnauthorized: () => {} })
beforeEach(() => { history.replaceState(null, '', '/#/sessions/k/history') })
afterEach(() => resetFolds())

const now = Date.now() / 1000
const HISTORY = {
  key: 'k',
  sites: [
    { site: 'the-internet.herokuapp.com', url: 'https://the-internet.herokuapp.com/secure', at: now - 60,
      saved: { cookies: 2, local: 2, session: 0 },
      secrets: [{ name: 'the-internet', description: 'Public demo login', keys: ['username', 'password'] }] },
    { site: 'grafana.example.com', url: 'https://grafana.example.com/d/selenium-grid', at: now - 3600,
      saved: { cookies: 5, local: 12, session: 0 },
      secrets: [{ name: 'grafana', description: 'LDAP sign-in for Grafana', keys: ['username', 'password'] }] },
    { site: 'example.com', url: 'https://example.com/', at: now - 3 * 3600, saved: null, secrets: [] },
    { site: 'admin.example.com', url: 'https://admin.example.com/flow/', at: now - 5 * 3600, saved: null,
      secrets: [{ name: 'selenium-admin', description: 'Admin token for this server', keys: ['token'] }] },
  ],
}
const counted = (origin: string, local: number) => ({ origin, local_storage: local, session_storage: 0 })
const empty = (site: string) => ({ site, uri: '', cookies: [], storage: [], own_cookies: [], kept_shared: [] })
const SITE_DATA = {
  key: 'k', saved_at: now - 120, uri: '',
  sites: [
    { site: 'the-internet.herokuapp.com', uri: '', cookies: 2, storage: [counted('https://the-internet.herokuapp.com', 2)] },
    { site: 'grafana.example.com', uri: '', cookies: 5, storage: [counted('https://grafana.example.com', 12)] },
  ],
  details: { 'the-internet.herokuapp.com': empty('the-internet.herokuapp.com'), 'grafana.example.com': empty('grafana.example.com') },
}
const row = { key: 'k', name: 'mine', live: true, attached: true, session_id: 'b1', files_rev: 1, flows_rev: 1, files_count: 0, history_count: 4, history_rev: 'h1' }

function setup(routes = {}) {
  const net = fakeFetch({
    'GET /admin/sessions/k/files': { body: { session: row, downloads: [], screenshots: [], files: [], browser: true } },
    'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1, session: 'k' } },
    'GET /admin/sessions/k/site-data': { body: SITE_DATA },
    'GET /admin/sessions/k/history': { body: HISTORY },
    ...routes,
  })
  const live = new Live(api, '')
  const r = render(SessionDetail, { key: 'k', tab: 'history', flow: undefined, api, live, root: '' })
  return { ...r, ...net, live }
}
const visits = (c: HTMLElement) => [...c.querySelectorAll('#paneHistory section.section')] as HTMLElement[]
const loaded = (c: HTMLElement, n = 4) => vi.waitFor(() => expect(visits(c)).toHaveLength(n))
const modal = () => document.querySelector('.modal') as HTMLElement

test('History is the fourth subtab and counts the hosts listed', async () => {
  const { container } = setup()
  const tabs = [...container.querySelectorAll('#sessionTabs button')].map((b) => b.firstChild!.textContent!.trim())
  expect(tabs).toEqual(['Files', 'Flows', 'Site data', 'History'])
  expect(screen.getByRole('tab', { name: /History/ })).toHaveAttribute('aria-selected', 'true')
  await loaded(container)
  expect(container.querySelector('#historyTotal')).toHaveTextContent('4')
})

test('the current site is on top: its URL, when, and what is saved there', async () => {
  const { container } = setup()
  await loaded(container)
  const [a, b, c] = visits(container)
  expect(a.querySelector('.title')).toHaveTextContent('https://the-internet.herokuapp.com/secure')
  expect(a.querySelector('.head')).toHaveTextContent('1m ago · 1 secret')
  expect(a.querySelector('.head .pill')).toHaveTextContent('2 cookies · 2 local')
  expect(b.querySelector('.head')).toHaveTextContent('1h ago · 1 secret')
  expect(b.querySelector('.head .pill')).toHaveTextContent('5 cookies · 12 local')
  expect(c.querySelector('.title')).toHaveTextContent('https://example.com/')
  expect(c.querySelector('.head')).not.toHaveTextContent('secret')
  expect(c.querySelector('.head .pill')).toBeNull()
})

test('rows fold like Site data: the top one opens to its secrets, the rest stay shut', async () => {
  const { container } = setup()
  await loaded(container)
  const [a, b, c] = visits(container)
  expect(a.querySelector('.title')).toHaveAttribute('aria-expanded', 'true')
  const line = a.querySelector('.line.secret')!
  expect(line).toHaveTextContent('🔑 the-internet')
  expect(line).toHaveTextContent('Public demo login')
  expect([...line.querySelectorAll('.pill')].map((p) => p.textContent)).toEqual(['username', 'password'])
  expect(b.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
  expect(b.querySelector('.line.secret')).toBeNull()
  await fireEvent.click(b.querySelector('.title')!)
  await vi.waitFor(() => expect(b.querySelector('.line.secret')).toHaveTextContent('🔑 grafana'))
  // Nothing to open: no caret button at all.
  expect(c.querySelector('[aria-expanded]')).toBeNull()
  expect(c.querySelector('.line.secret')).toBeNull()
})

test('the saved pill opens that host in Site data, unfolded', async () => {
  const { container, rerender } = setup()
  await loaded(container)
  await fireEvent.click(visits(container)[1].querySelector('.head .pill')!)
  expect(location.hash).toBe('#/sessions/k/site-data/grafana.example.com')
  await rerender({ tab: 'site-data', site: 'grafana.example.com' })
  const grafana = () => [...container.querySelectorAll('#paneSiteData section.section')]
    .find((s) => s.querySelector('.title')!.textContent!.includes('grafana')) as HTMLElement
  await vi.waitFor(() => expect(grafana().querySelector('.title')).toHaveAttribute('aria-expanded', 'true'))
})

test('Clear is offered only while there is more than the current site', async () => {
  const { container, unmount } = setup()
  await loaded(container)
  expect(container.querySelector('#clearHistory')).not.toBeNull()
  unmount()
  const again = setup({ 'GET /admin/sessions/k/history': { body: { key: 'k', sites: HISTORY.sites.slice(0, 1) } } })
  await loaded(again.container, 1)
  expect(again.container.querySelector('#clearHistory')).toBeNull()
})

test('Clear confirms what goes and what stays, then DELETEs the history and reloads', async () => {
  const { container, calls } = setup({ 'DELETE /admin/sessions/k/history': { body: { cleared: [] } } })
  await loaded(container)
  await fireEvent.click(container.querySelector('#clearHistory')!)
  expect(modal().querySelector('.head')).toHaveTextContent('Clear history')
  expect(modal()).toHaveTextContent('History only: site data and the browser are untouched.')
  expect([...modal().querySelectorAll('.pair')].map((p) => p.textContent)).toEqual([
    'goesgrafana.example.com · example.com · admin.example.com',
    'staysthe-internet.herokuapp.com, the current site',
  ])
  const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/history')).length
  const before = gets()
  await fireEvent.click(within(modal()).getByRole('button', { name: 'Clear' }))
  await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/history']))
  await vi.waitFor(() => expect(gets()).toBe(before + 1))
})

test('a pushed history_rev change reloads; the same rev does not', async () => {
  const { container, calls, live } = setup()
  await loaded(container)
  const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/history')).length
  expect(gets()).toBe(1)
  live.data = { sessions: [{ ...row, history_rev: 'h1' }] }
  await new Promise((r) => setTimeout(r, 10))
  expect(gets()).toBe(1)
  live.data = { sessions: [{ ...row, history_rev: 'h2' }] }
  await vi.waitFor(() => expect(gets()).toBe(2))
})

test('Loading… first, an error in the pane, and nowhere yet when empty', async () => {
  const d = deferred<{ status: number; body: unknown }>()
  const { container, unmount } = setup({ 'GET /admin/sessions/k/history': () => d.promise })
  expect(within(container.querySelector('#paneHistory') as HTMLElement).getByText('Loading…')).toBeInTheDocument()
  d.resolve({ status: 500, body: { error: 'nope' } })
  await vi.waitFor(() => expect(screen.getByText('nope')).toHaveClass('error'))
  unmount()
  const none = setup({ 'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } } })
  await vi.waitFor(() => expect(none.container.querySelector('#paneHistory')).toHaveTextContent('Nowhere yet.'))
})

Append to ui/src/admin/router.test.ts:

test('History is a tab, and a Site data host rides in the hash', () => {
  expect(parse('#/sessions/k/history')).toEqual({ view: 'session', key: 'k', tab: 'history', flow: undefined, site: undefined })
  expect(parse('#/sessions/k/site-data/app.example.com')).toEqual({ view: 'session', key: 'k', tab: 'site-data', flow: undefined, site: 'app.example.com' })
  expect(parse('#/sessions/k/files/x')).toEqual({ view: 'session', key: 'k', tab: 'files', flow: undefined, site: undefined })
  expect(hashes.history('a b')).toBe('#/sessions/a%20b/history')
  expect(parse(hashes.site('a b', 'h.example.com'))).toEqual({ view: 'session', key: 'a b', tab: 'site-data', flow: undefined, site: 'h.example.com' })
})

Append to ui/src/admin/session.test.ts:

test('a pushed row refetches the history only when history_rev moves', async () => {
  const { calls } = fakeFetch({
    'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } },
    'GET /admin/sessions/k/files': { body: files() },
    'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1 } },
  })
  const m = new SessionModel('k', api)
  const loads = () => calls.filter((c) => c.path === '/admin/sessions/k/history').length
  const push = (rev: string) => m.onPushed({ sessions: [{ key: 'k', live: true, session_id: 'b1', files_rev: 1, flows_rev: 1, history_rev: rev }] }, () => null, () => {}, () => {})
  push('h1')
  expect(loads()).toBe(1)
  push('h1')
  expect(loads()).toBe(1)
  push('h2')
  expect(loads()).toBe(2)
})

In ui/src/admin/session.test.ts, the two tests a pushed row refetches files only when the stamp moves (F8) and a pushed row refetches flows only when flows_rev moves (F8, W7) (lines 66–87) count fetches, and a history load that 404s is retried on every push. In both, the opening of the fakeFetch call

fakeFetch({ 'GET /admin/sessions/k/site-data': { body: { sites: [], details: {} } }, 

becomes

fakeFetch({ 'GET /admin/sessions/k/site-data': { body: { sites: [], details: {} } }, 'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } }, 

In ui/src/admin/SiteDataPane.test.ts, add the history route to base, so the History pane loads quietly beside it:

  'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } },
  • Step 2: Run to verify they fail

Run: cd ui && npx vitest run src/admin/HistoryPane.test.ts src/admin/router.test.ts src/admin/session.test.ts Expected: FAIL — no #paneHistory, no History subtab, hashes.history is not a function, no history load on push.

  • Step 3: Implement

ui/src/lib/types.ts — in SessionRow, after site_data_rev?: string | null:

  history_count?: number | null
  history_rev?: string | null

and append:

/** What the snapshot holds for one host, as History's pill counts it. */
export interface SavedCounts { cookies: number; local: number; session: number }
/** A host the session landed on: its latest page, and what can be used there. */
export interface HistoryRow {
  site: string
  url: string
  at: number
  saved: SavedCounts | null
  secrets: SiteSecret[]
}
export interface HistoryPayload { key: string; sites: HistoryRow[] }

ui/src/admin/router.svelte.ts — replace whole:

/* The hash, not the history API: every other path under this mount is a real
   endpoint, and a history route would need a catch-all that answers a mistyped
   API call with HTML. A hash never reaches the server. */
export type Tab = 'files' | 'flows' | 'site-data' | 'history'
export type Route =
  | { view: 'list' }
  | { view: 'console' }
  | { view: 'secrets' }
  | { view: 'settings' }
  | { view: 'session'; key: string; tab: Tab; flow: string | undefined; site: string | undefined }

const TABS: readonly string[] = ['flows', 'site-data', 'history']

export function parse(hash: string): Route {
  const [view, key, tab, rest] = hash.replace(/^#\/?/, '').split('/')
  if (view === 'console') return { view: 'console' }
  if (view === 'secrets') return { view: 'secrets' }
  if (view === 'settings') return { view: 'settings' }
  if (view === 'sessions' && key) {
    // parse() runs at module load: a bad pasted `%` must not stop the app booting.
    try {
      const picked = (TABS.includes(tab) ? tab : 'files') as Tab
      const named = rest ? decodeURIComponent(rest) : undefined
      return {
        view: 'session',
        key: decodeURIComponent(key),
        tab: picked,
        // The fourth segment is a flow on Flows and a host on Site data.
        flow: picked === 'flows' ? named : undefined,
        site: picked === 'site-data' ? named : undefined,
      }
    } catch {
      return { view: 'list' }
    }
  }
  return { view: 'list' }
}

const enc = encodeURIComponent
export const hashes = {
  list: '#/',
  console: '#/console',
  secrets: '#/secrets',
  settings: '#/settings',
  session: (key: string) => '#/sessions/' + enc(key),
  flows: (key: string) => '#/sessions/' + enc(key) + '/flows',
  siteData: (key: string) => '#/sessions/' + enc(key) + '/site-data',
  site: (key: string, host: string) => '#/sessions/' + enc(key) + '/site-data/' + enc(host),
  history: (key: string) => '#/sessions/' + enc(key) + '/history',
  flow: (key: string, name: string) => '#/sessions/' + enc(key) + '/flows/' + enc(name),
}

export const router = $state<{ route: Route }>({ route: parse(location.hash) })

export const go = (hash: string) => { location.hash = hash }

/* In the address bar and the route, without a history entry and without
   hashchange: picking through a dozen flows should not take a dozen Backs. */
export function replace(hash: string) {
  history.replaceState(null, '', hash)
  router.route = parse(hash)
}

/* The route, read from the address bar: at mount, and on every hashchange
   (Admin's `<svelte:window onhashchange>`). */
export function sync() {
  router.route = parse(location.hash)
}

ui/src/admin/session.svelte.ts — add HistoryPayload to the type import; after siteDataError add:

  history = $state.raw<HistoryPayload | null>(null)
  historyError = $state<string | null>(null)

after #shownSiteData add:

  #shownHistory: string | null | undefined = undefined
  #historyLoads = new Latest()

after loadSiteData add:

  loadHistory(): Promise<void> {
    if (this.#disposed) return Promise.resolve()
    return this.#historyLoads.run(
      (signal) => this.#api<HistoryPayload>(sessionPath(this.key, '/history'), 'GET', undefined, signal),
      (data) => { this.history = data; this.historyError = null },
      (e) => {
        this.historyError = e.message
        // Unshown again, so the next push at the same rev tries once more.
        this.#shownHistory = undefined
      },
    )
  }

in #showRow, after the site data check:

    if ((row.history_rev ?? null) !== this.#shownHistory) {
      this.#shownHistory = row.history_rev ?? null
      void this.loadHistory()
    }

and in dispose, this.#historyLoads.abort() after this.#siteLoads.abort().

ui/src/admin/HistoryPane.svelte:

<script lang="ts">
  import { untrack } from 'svelte'
  import { ago } from '../lib/format'
  import type { HistoryRow, SavedCounts } from '../lib/types'
  import { sessionPath, type Api } from './api'
  import { folds } from './folds.svelte'
  import type { ModalSpec } from './modal'
  import { go, hashes } from './router.svelte'
  import Section from './Section.svelte'
  import type { SessionModel } from './session.svelte'

  let { m, api, ask, refuseIfGone, isGone, hidden }: {
    m: SessionModel
    api: Api
    ask: <T>(spec: ModalSpec<T>) => void
    refuseIfGone: () => void
    isGone: () => boolean
    hidden: boolean
  } = $props()

  const data = $derived(m.history)
  // Ends in `Section`: Section derives its body's id by replacing that.
  const idOf = (r: HistoryRow) => 'visit-' + m.key + ':' + r.site + 'Section'

  // The top row opens, the rest stay shut — once; after that the fold is the
  // reader's, as on Site data.
  $effect.pre(() => {
    const sites = data?.sites ?? []
    untrack(() => sites.forEach((r, i) => { folds[idOf(r)] ??= i === 0 }))
  })

  const plural = (n: number, one: string) => n + ' ' + one + (n === 1 ? '' : 's')
  // Zero counts are left out: "2 cookies · 2 local".
  const savedText = (s: SavedCounts) => [
    s.cookies ? plural(s.cookies, 'cookie') : '',
    s.local ? s.local + ' local' : '',
    s.session ? s.session + ' session' : '',
  ].filter(Boolean).join(' · ')
  const when = (r: HistoryRow) =>
    [ago(r.at * 1000), r.secrets.length ? plural(r.secrets.length, 'secret') : ''].filter(Boolean).join(' · ')

  interface Clear { goes: string[]; stays: string }

  function clear() {
    const sites = data?.sites ?? []
    ask<Clear>({
      title: 'Clear history',
      body: clearBody,
      data: { goes: sites.slice(1).map((r) => r.site), stays: sites[0]?.site ?? '' },
      confirm: 'Clear', danger: true,
      onconfirm: async () => {
        refuseIfGone()
        await api(sessionPath(m.key, '/history'), 'DELETE')
        if (!isGone()) void m.loadHistory()
      },
    })
  }
</script>

{#snippet clearBody(c: Clear)}
  <p>History only: site data and the browser are untouched.</p>
  <div class="pairs fate-list">
    <div class="pair"><span class="pk">goes</span><span class="pv">{c.goes.join(' · ')}</span></div>
    <div class="pair"><span class="pk">stays</span><span class="pv">{c.stays}, the current site</span></div>
  </div>
{/snippet}

{#snippet about(r: HistoryRow)}
  {@const saved = r.saved ? savedText(r.saved) : ''}
  <span class="small muted">{when(r)}</span>
  {#if saved}
    <button class="pill link" onclick={() => go(hashes.site(m.key, r.site))}>{saved}</button>
  {/if}
{/snippet}

<div id="paneHistory" {hidden}>
  {#if m.historyError}
    <div class="empty error">{m.historyError}</div>
  {:else if !data}
    <div class="empty">Loading…</div>
  {:else if !data.sites.length}
    <div class="empty">Nowhere yet.</div>
  {:else}
    {#if data.sites.length > 1}
      <div class="row bar">
        <span class="grow"></span>
        <button id="clearHistory" class="danger" onclick={clear}>Clear</button>
      </div>
    {/if}
    {#each data.sites as r (r.site)}
      {#if r.secrets.length}
        <Section id={idOf(r)} title={r.url}>
          {#snippet summary()}{@render about(r)}{/snippet}
          {#each r.secrets as s (s.name)}
            <div class="line secret">
              <span class="key"><span>🔑</span> <code>{s.name}</code></span>
              <span class="value">{s.description ?? ''}</span>
              <span class="flags">{#each s.keys as k (k)}<span class="pill">{k}</span>{/each}</span>
            </div>
          {/each}
        </Section>
      {:else}
        <!-- Nothing to open: the head alone, the caret's place kept so the
             URLs line up. -->
        <section class="section" id={idOf(r)}>
          <div class="head">
            <span class="title"><span class="caret gap" aria-hidden="true">▾</span>{r.url}</span>
            {@render about(r)}
          </div>
        </section>
      {/if}
    {/each}
  {/if}
</div>

<style>
  .bar { margin-bottom: 12px; }
  .caret.gap { visibility: hidden; }
  .pill.link { font: inherit; font-size: 12px; background: none; cursor: pointer; }
  .pill.link:hover { color: var(--accent); border-color: currentColor; }
  .line { display: grid; grid-template-columns: 240px 1fr auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); }
  .key { font-size: 12px; overflow-wrap: anywhere; }
  .value { overflow-wrap: anywhere; min-width: 0; }
  .flags { display: inline-flex; gap: 4px; justify-content: flex-end; }
  .fate-list { padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--ink) 5%, transparent); }
  .fate-list .pk { width: 48px; }
</style>

Check in the browser (Task 11's live check) that the plain <span class="title"> of a row with nothing to open matches the <button class="title"> beside it; if app.css styles only the button, add .section .head span.title to the same rule.

ui/src/admin/SessionDetail.svelte:

  • imports: add import HistoryPane from './HistoryPane.svelte' (after FlowsPane), and change import { go, hashes, replace } from './router.svelte' to import { go, hashes, replace, type Tab } from './router.svelte';
  • the props (lines 16–23) become:
  let { key, tab, flow, site, api, live, root }: {
    key: string
    tab: Tab
    flow: string | undefined
    /** A Site data host linked from History. */
    site?: string
    api: Api
    live: Live
    root: string
  } = $props()
  • after siteDataTotal:
  const historyTotal = $derived(m.historyError || !m.history ? '' : String(m.history.sites.length))
  • after the tabSiteData button:
    <button id="tabHistory" role="tab" aria-selected={tab === 'history'} onclick={() => go(hashes.history(key))}>History <span id="historyTotal" class="count">{historyTotal}</span></button>
  • the pane line <SiteDataPane … /> becomes, with History after it:
  <SiteDataPane {m} {api} {ask} {refuseIfGone} isGone={() => destroyed} {site} hidden={tab !== 'site-data'} />
  <HistoryPane {m} {api} {ask} {refuseIfGone} isGone={() => destroyed} hidden={tab !== 'history'} />

ui/src/admin/Admin.svelte — line 101 becomes:

            <SessionDetail key={route.key} tab={route.tab} flow={route.flow} site={route.site} {api} {live} root={ROOT} />

ui/src/admin/SiteDataPane.svelte:

  • import { untrack } from 'svelte' becomes import { tick, untrack } from 'svelte';
  • the props become:
  let { m, api, ask, refuseIfGone, isGone, site, hidden }: {
    m: SessionModel
    api: Api
    ask: <T>(spec: ModalSpec<T>) => void
    refuseIfGone: () => void
    isGone: () => boolean
    /** A host linked from History: opened and brought into view. */
    site?: string
    hidden: boolean
  } = $props()
  • after the first-row $effect.pre:
  // A link from History opens its host and brings it into view, once per link.
  let linked: string | undefined
  $effect.pre(() => {
    const r = (data?.sites ?? []).find((x) => x.site === site)
    if (!r || site === linked) return
    linked = site
    untrack(() => { folds[idOf(r)] = true })
    void tick().then(() => document.getElementById(idOf(r))?.scrollIntoView?.({ block: 'start' }))
  })
  • the whole {#each data.sites as r (r.site)} block becomes (the <Section> and its content are Task 9's, now inside a key):
    {#each data.sites as r (r.site)}
      {@const d = data.details[r.site]}
      <!-- Remounted when it becomes the linked row: a Section reads its fold
           once, at mount. -->
      {#key r.site === site}
        <Section id={idOf(r)} title={titleOf(r)}>
          {#snippet summary()}<span class="small muted">{counts(r)}</span>{/snippet}
          {#snippet actions()}<button class="danger" onclick={() => forget(r)}>Forget</button>{/snippet}
          {#if d}
            <h3>Cookies</h3>
            {#each d.cookies as c (c.name + c.domain + c.path)}
              <div class="line cookie">
                <code class="key">{c.name}</code>
                <span class="value clip" title={c.value}>{c.value}</span>
                <span class="small muted">{c.domain} · {expires(c)}</span>
                <span class="flags">
                  {#if c.http_only}<span class="pill">httpOnly</span>{/if}
                  {#if c.secure}<span class="pill">secure</span>{/if}
                  {#if c.shared}<span class="pill shared">shared</span>{/if}
                </span>
              </div>
            {:else}
              <div class="line"><span class="value muted">none</span></div>
            {/each}
            {#each groups(d) as e (e.origin)}
              <h3>Local storage{@render from(d, e)}</h3>
              {@render kv(Object.entries(e.local_storage))}
              <h3>Session storage{@render from(d, e)}</h3>
              {@render kv(Object.entries(e.session_storage))}
            {/each}
          {/if}
        </Section>
      {/key}
    {/each}
  • Step 4: Run to verify they pass — cd ui && npx vitest run && npm run -s check && npm run -s lint → PASS, 0 errors, 0 warnings; npm --prefix ui run build && npm --prefix ui run size succeeds — the admin surface within its 45 KB gzipped budget (39.0 KB after Task 10 in a dry run).

  • Step 5: Break it on purpose — in SiteDataPane.svelte, remove the {#key r.site === site} wrapper (keep the Section); confirm the saved pill opens that host in Site data, unfolded fails; restore. Then in HistoryPane.svelte change data.sites.length > 1 to data.sites.length > 0; confirm Clear is offered only while there is more than the current site fails; restore. Then change i === 0 to false in the fold effect; confirm rows fold like Site data: the top one opens to its secrets, the rest stay shut fails; restore.

  • Step 6: Commit

git add ui/src/lib/types.ts ui/src/admin/HistoryPane.svelte ui/src/admin/HistoryPane.test.ts \
  ui/src/admin/router.svelte.ts ui/src/admin/router.test.ts ui/src/admin/session.svelte.ts \
  ui/src/admin/session.test.ts ui/src/admin/SessionDetail.svelte ui/src/admin/Admin.svelte \
  ui/src/admin/SiteDataPane.svelte ui/src/admin/SiteDataPane.test.ts
git commit -m "Site data round 2: the History tab — where the session has been, joined by host" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: The skill, the docs, the wiki

Files:

  • Rewrite: skills/selenium-flow/references/SITE_DATA.md, wiki/notes/save_site_data.notes.md
  • Modify: skills/selenium-flow/SKILL.md (line 169), skills/selenium-flow/references/SESSIONS.md (lines 69–73), AGENTS.md (the ### Site data section, lines 398–410), CHANGELOG.md ([Unreleased] only)
  • Regenerate: wiki/*.md (a submodule: committed inside wiki/ first, then the pointer)

Interfaces:

  • Consumes: everything above — the docs describe what Tasks 1–10 built, in the voice of the other references: terse, example-led.

  • Produces: no code. tests/test_skill.py, tests/test_guidance.py, tests/test_readme.py and the wiki check keep passing. The integration flow tests/integration/flows/save-site-data.yaml is unchanged and still valid (tests/test_flowdoc.py checks it).

  • Step 1: Rewrite the skill's reference — skills/selenium-flow/references/SITE_DATA.md, whole:

# Site data — stay signed in across a reaped browser

A new browser starts empty. `save_site_data` keeps this one's cookies and the
storage of every site this session has been to, and every browser opened for the
session afterwards has them back — the one that replaces a reaped browser, or the
one after `end_browser()`.

## When to save

After a sign-in you have **confirmed**, never before: check the page shows you are
in, then save once.

```
write(url="https://app.example.com/login", selector={"css": "#user"}, text="a@example.com")
write(selector={"css": "#pw"}, secret={"name": "app", "key": "password"})
interact(action="click", selector={"css": "button[type=submit]"})
assert(script="return !!document.querySelector('#avatar')")   # am I in?
save_site_data()
```

One save covers every site the sign-in touched — the identity provider it went
through, and an app you signed in to earlier in the session. Save again after a
setting you want kept.

Each save **replaces** the last. A save after signing out saves you signed out.

The result names what was kept, never a value:

```json
{"saved": {"cookies": 14,
           "sites": ["https://app.example.com", "https://sso.example.com"],
           "skipped": []},
 "uri": "session://site-data"}
```

`skipped` names a site whose storage was not read this time; it keeps what the
last save had for it. "a service worker answered: save while on this site" means
the site's own worker answered first: `navigate` there and save again, and it is
read from the page itself.

## What comes back

Everything is in place before `open_session` returns: the cookies, every site's
localStorage, and the sessionStorage of the page you saved on. `open_session`
says which sites, and never a value:

```json
{"site_data": {"restored": ["app.example.com", "sso.example.com"], "skipped": [],
               "uri": "session://site-data"}}
```

No `site_data` key means nothing was saved. When the Grid reaps your browser, the
next call reopens it, restores the same way, and that call's result carries this
report once.

`skipped` names a cookie or a site that did not come back, and why. "the browser
did not keep it" means the browser took a cookie without an error and dropped it;
its host is then not in `restored`.

## Restored but still signed out

The site ended its own session — it expired or was revoked. Sign in again, then
`save_site_data()` again; nothing else is wrong.

## Start as a new user

```
open_session(restore_site_data=false)
```

Opens with nothing **and deletes** what was saved, so the next open is empty too.
Where the session has been is kept.

## Debugging with execute_script

Set or clear a flag to see what a reopened browser keeps:

```
execute_script(script="localStorage.setItem('seen', '1')")
save_site_data()
end_browser()
open_session()
execute_script(script="return localStorage.getItem('seen')")   # "1"
```

`document.cookie` shows what the page can see. `session://site-data/app.example.com`
shows what was saved, including httpOnly cookies the page cannot — their values
read `•••`, here and in the admin UI.

## Limits

- IndexedDB is not saved (Firebase Auth keeps its sign-in there).
- sessionStorage belongs to one tab, so only the page you save on keeps it.
- A browser opened with `insecure=true` gets none of it back: it accepts any
  certificate, so saved cookies could reach whoever sits in the middle. What was
  saved is kept for the next secure browser.
- A script cannot set an httpOnly cookie; it comes back through the browser's own
  channel.
- Stored on the session and expires with it. Never on this server's disk; with the
  Redis store it is as durable as Redis.
- A site that stores what you typed makes it readable in site data — a token typed
  through a secret and kept in localStorage shows in `session://site-data/{site}`.
  httpOnly cookies stay masked.
  • Step 2: The other skill pages

skills/selenium-flow/SKILL.md, the tool-table row for save_site_data (line 169) becomes:

| `save_site_data` | keep the cookies and storage of every site you have been to, so a replacement comes back signed in | `url` |

skills/selenium-flow/references/SESSIONS.md, the paragraph at lines 69–73 becomes:

A refresh restores what you saved with `save_site_data` too — cookies and every
site's storage, so you are still signed in — and the call that noticed says so
in `site_data` (`skill://selenium-flow/references/SITE_DATA.md`). What it cannot
restore is in-page state the URL does not capture — scroll position, an open
dropdown, an unsubmitted form — and anything you never saved. If a long pause is
coming before a step that depends on unsaved state, do that step first.
  • Step 3: The wiki's note — wiki/notes/save_site_data.notes.md, whole:
## When to save

Save once a sign-in is confirmed, not before: check the page shows you are in,
then call `save_site_data`. One save keeps every cookie, the localStorage of
every site the session has been to — read in a hidden tab that never loads the
site — and the sessionStorage of the page it is on. Each save replaces the last,
so a save after signing out saves you signed out.

## What restore does

Every browser opened for the session has it all back before `open_session`
returns, including the one that replaces a reaped browser: cookies first, then
each site's storage, then the page's sessionStorage, then the first page.
`open_session` names the sites in `site_data`; after a silent reopen, the call
that noticed carries that report once. `open_session(restore_site_data=false)`
opens with nothing and deletes what was saved, which is how you start as a new
user; where the session has been is kept.

## Limits

IndexedDB is not saved, and sessionStorage only for the page saved on. A site
whose service worker answers the hidden tab is not read there: it keeps its last
saved storage, and a save made while on it reads it directly. An httpOnly cookie
cannot be set from a script, so it is restored through the browser's own channel
and shown as `•••` on every surface. Saved data lives with the session's record
and expires with it: never on this server's disk, and with the Redis store as
durable as Redis.
  • Step 4: AGENTS.md — replace the ### Site data section (from its heading through the bullet that ends fn only computes, and may run twice.`, lines 398–410) with:
### Site data

- Saved **explicitly** by `save_site_data`, never captured per call. A save is a **snapshot**
  that replaces the last whole (`site_data.snapshot`): the jar, the localStorage of every origin
  in the record's `history`, and the page's sessionStorage. An origin that cannot be read keeps
  its last storage; over 1 MB the oldest-visited origins go first.
- `SessionRecord.history` is where the session has been: one entry per origin, newest first,
  written by `touch` (a flow run: once at the end, every step's page in order — and, before a
  `save_site_data` step, the pages reached so far, so the save reads them). `record.url` is
  `history[0].url`. A withheld URL (§F1.24) or a page with no origin records nothing.
- Other origins are reached through `browser.spare_tab`: a background tab whose requests a BiDi
  intercept answers with a marked blank page, so the site never loads. A save reads there; a
  restore writes there, then sets sessionStorage in the main tab the same way, all before the
  first page. A page without the marker is a service worker's and is never read. No CDP.
- Kept on `SessionRecord` in the store, never on this server's disk (with the Redis store it is
  as durable as Redis), never logged — `main.py` holds Selenium's wire loggers at INFO for that;
  it expires with the record. httpOnly values are shown as `•••` on every surface.
- An action returns the capture under `CAPTURED`; `SessionManager.settle` stores it and strips
  it, so it is never returned. `act` and every flow step go through `settle`.
- A silent reopen's report waits on `SessionRecord.reopened` until `touch` hands it to the first
  result from that browser — for a flow, to the run.
- The admin tabs keep the two apart: History (the history joined by host with secrets and the
  snapshot's counts) and Site data (the snapshot). Forget and both Clears change the store
  only — never the live browser, never the other tab's data.
- Every record write goes through `store.update(key, fn)`, which applies `fn` to the record as
  it is at write time (memory: a per-key lock; Redis: `WATCH`/`MULTI`/`EXEC`, retried, then
  `StoreConflict`). A whole record read before slow work and `set` after reverts a browser
  opened or a save made meanwhile. Do slow work outside `fn`; `fn` only computes, and may run
  twice.
  • Step 5: CHANGELOG.md — under ## [Unreleased] only (round 1 has not been released, so its lines are still ours to correct). Replace
- `save_site_data` keeps a session's cookies and storage, so a reopened browser comes back signed in.

with

- `save_site_data` keeps the cookies and storage of every site a session has been to, so a reopened browser comes back signed in to all of them.

after - The admin UI has a Site data tab. add

- The admin UI has a History tab: where a session has been, with the secrets allowed and what is saved there.

and delete - Site data a save evicts over the size cap no longer comes back in a browser already open. (the preload script it was about is gone).

  • Step 6: Regenerate the wiki and run the doc checks
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py --check
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_skill.py tests/test_readme.py \
  tests/test_guidance.py tests/test_flowdoc.py tests/test_wiki.py \
  --deselect tests/test_wiki.py::test_the_generated_pages_are_current

Expected: the generator rewrites the action pages whose shared site-data hint changed (open_session, navigate, interact, … save_site_data, list_site_data, get_site_data, run_flow, Actions) and --check then reports every page current; the tests PASS. Then the whole unit suite → PASS, and ruff clean.

  • Step 7: Commit — the wiki first, inside its submodule, then the repo with the new pointer:
git -C wiki add -A
git -C wiki commit -m "Site data round 2: one save keeps every site; History beside Site data" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git add skills/selenium-flow/references/SITE_DATA.md skills/selenium-flow/SKILL.md \
  skills/selenium-flow/references/SESSIONS.md AGENTS.md CHANGELOG.md wiki
git commit -m "Site data round 2: the skill, the docs and the wiki" \
  -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

(wiki/notes/save_site_data.notes.md is inside the submodule and goes in its commit. Pushing the wiki submodule is the controller's, with the PR: an unpushed submodule SHA fails CI's Test in about ten seconds with no pytest output.)


Out of the task loop (controller)

  • The whole branch, green: the unit suite, ruff, generate_wiki.py --check, and in ui/: npx vitest run && npm run -s check && npm run -s lint && npm run -s build && npm run -s size. (Every task of this round was applied to a scratch copy of the repo while this plan was written: 1550 Python tests, 195 UI tests, ruff, svelte-check, eslint, the build and the size budget all passed on the end state.)

  • Live, before the PR (spec, Testing): Chrome and Firefox, two apps signed in, one save, a real end and reopen, both signed in, timed. Run the branch in the pod against the real Grid:

GRID_URL=http://selenium-grid-selenium-hub.flow.svc.cluster.local:4444 AUTH_TOKEN=probe-token \
  PORT=8766 PYTHONPATH=.:$S/deps python3 -S -m kubed.selenium_flow

and, in another shell, once per browser (bash live.sh chrome, then bash live.sh firefox):

#!/usr/bin/env bash
# live.sh <browser>: two apps signed in, one save, a real end and reopen.
B=$1; SID=live-$1; U=http://127.0.0.1:8766
H=(-s -m 180 -H "Authorization: Bearer probe-token" -H "Content-Type: application/json")
call(){ curl "${H[@]}" -X "$1" "$U$2?session=$SID" ${3:+-d "$3"}; echo; }
py(){ python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; }
ms(){ echo $(( ($(date +%s%N) - $1) / 1000000 )); }

echo "== $B: a clean start, then app 1 (an httpOnly cookie, and a localStorage mark)"
T=$(date +%s%N); call POST /browser "{\"browser\":\"$B\",\"url\":\"https://the-internet.herokuapp.com/login\",\"restore_site_data\":false}" >/dev/null; echo "plain open: $(ms $T) ms"
call POST /browser/write '{"selector":{"css":"#username"},"text":"tomsmith"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#password"},"text":"SuperSecretPassword!"}' >/dev/null
call POST /browser/interact/click '{"selector":{"css":"button[type=submit]"}}' | py 'd.get("url")'
call POST /browser/script '{"script":"localStorage.setItem(\"probe\",\"kept\"); return true"}' >/dev/null

echo "== app 2 (a cookie sign-in, its cart in localStorage)"
call POST /browser/navigate '{"url":"https://www.saucedemo.com/"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#user-name"},"text":"standard_user"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#password"},"text":"secret_sauce"}' >/dev/null
call POST /browser/interact/click '{"selector":{"css":"#login-button"}}' | py 'd.get("url")'
call POST /browser/interact/click '{"selector":{"css":"#add-to-cart-sauce-labs-backpack"}}' >/dev/null

echo "== one save, on app 2 — sites must name both origins"
T=$(date +%s%N); call POST /browser/save-site-data '{}' | py 'd.get("saved"), "_site_data_captured" in d'; echo "save: $(ms $T) ms"

echo "== a real end and reopen"
call DELETE /browser >/dev/null
T=$(date +%s%N); call POST /browser '{}' | py 'd.get("url"), d.get("site_data")'; echo "open with restore: $(ms $T) ms"
call POST /browser/extract '{"url":"https://the-internet.herokuapp.com/secure","selector":{"css":"h2"}}' | py '"app 1 at", d.get("url")'
call POST /browser/script '{"script":"return localStorage.getItem(\"probe\")"}' | py '"app 1 localStorage:", d.get("result")'
call POST /browser/navigate '{"url":"https://www.saucedemo.com/inventory.html"}' | py '"app 2 at", d.get("url")'
call POST /browser/script '{"script":"return localStorage.getItem(\"cart-contents\")"}' | py '"app 2 cart:", d.get("result")'

echo "== the admin views"
curl "${H[@]}" "$U/admin/sessions/$SID/history" | py '[(s["site"], s["saved"]) for s in d["sites"]]'
curl "${H[@]}" "$U/admin/sessions/$SID/site-data" | py '[(s["site"], s["cookies"], s["storage"]) for s in d["sites"]]'

echo "== restore off: deleted, history kept"
call POST /browser '{"restore_site_data":false}' | py 'd.get("site_data")'
curl "${H[@]}" "$U/admin/sessions/$SID/site-data" | py '"site data after:", d["sites"]'
curl "${H[@]}" "$U/admin/sessions/$SID/history" | py '"history after:", [s["site"] for s in d["sites"]]'
call DELETE /browser >/dev/null

Pass: the save's saved.sites names both https://www.saucedemo.com and https://the-internet.herokuapp.com; after the reopen app 1 stays on /secure, its probe reads kept, app 2 stays on /inventory.html with a cart, and site_data.restored names both hosts; restore_site_data=false answers {"forgotten": 2} (or more, with ad hosts), leaves Site data empty and History intact. Record both browsers' "open with restore" minus "plain open" — the restore's own cost; the spike predicts well under a second for a handful of origins. Then open the admin page on the live session and compare the two tabs with the Penpot boards.

  • The PR — ask Dr K first (one PR per session). Push the wiki submodule with it. Then the Copilot review loop: read, answer and resolve each thread with gh.

Spec → task

Spec section Task
The model · history: every call that lands bumps its origin; one per origin; expiry with session.ttl, top kept; cap 100; withheld URL and no-origin page record nothing; no values 1
The model · history: a flow writes its steps' origins in one update, in order; before a save step, the pages so far first 2, 8
The model · record.url is history[0].url; a reopen navigates there 1
Why WebDriver BiDi · browsingContext.create, network.addIntercept + provideResponse; the spike's polling and event-name findings 3
The model · site_data is one snapshot: shape, replaced whole, origins from the history (non-empty only), session for the page, 1 MB cap oldest first, a jar alone over it is a 400 4 (shape), 8
save_site_data: read the jar, the page, every other history origin in a spare tab; replace; a failed origin keeps its last storage, named in skipped; result shape; BiDi down is a 503; still a flow step; annotations unchanged 8
Service workers at save time: the marker, close without reading, skipped reason; restore unaffected 3 (marker), 8 (reported)
open_session(restore_site_data=true): cookies, then every origin's localStorage in the spare tab, then sessionStorage in the main tab, then land; the silent reopen too; fresh and insecure unchanged; best effort 4, 5
open_session(restore_site_data=false): the snapshot is deleted, the history stays 5 (tested; the behaviour is round 1's)
The hint: {restored, skipped, uri} only when something happened; {forgotten}; the first call after a silent reopen carries the report once; no arrival hint, no waiting 4, 5
Resources (agent): session://site-data, session://site-data/{site}, session://current 6 (listing in history order), 1 (url)
What round 2 removes: pending, waiting, the arrival hint, preload_source, arrive, retire, replace, Actions.retire_site_data, stale, _hold_pending, MARKER, per-origin saved_at 4, 5, 8
The admin UI: subtabs Files · Flows · Site data · History; neither tab stores a row 9, 10
History: hosts current first, rows fold like Site data (top one open), URL bold, "1m ago", saved pill linking to Site data, secret lines, secrets never make a row, the count, Clear only while more than the current site and its confirm, no per-row action 7 (API), 10 (UI)
Site data: one saved pill and Clear, rows history-first then alphabetical, no secrets, row head and counts, expanded per origin, shared cookies, the count, Forget and Clear confirms, neither touches the history or the browser, the empty line, the dropped lines 6 (API), 9 (UI)
Behind them: the five routes, token-gated; history_count, history_rev, site_data_count, site_data_rev 6, 7
The skill: one save covers the sign-in; restore complete at open_session; no arrival hint; the limits 11
Testing · unit cases named in the spec, BiDi faked at Grid.bidi, each new test broken once 1–10, each task's break step
Testing · test_surfaces, the declared shapes, the wiki 4, 5, 6, 8 (shapes); 11 (wiki)
Testing · the integration flow stays unchanged; 9 keeps its selectors
Testing · live before the PR controller
Non-goals (no per-call capture, no profile, no provenance, no eTLD+1, no setter, no agent delete, no sharing, no IndexedDB, no CDP) none of them is built

Round 1

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A session saves its cookies, localStorage and sessionStorage with save_site_data, and every browser opened for it — the silent reopen after a reap included — comes back with them.

Architecture: Pure logic in a new core/site_data.py (merge, view, forget, the preload script). BiDi lives behind one Grid.bidi(session_id) context manager, used only by save and restore. Actions captures and restores; SessionManager merges into the record, announces restores, and deletes on restore_site_data=false. Surfaces, admin API and a Svelte pane sit on top.

Tech Stack: Python 3.10–3.14, Selenium 4.49 (WebDriver BiDi storage and script modules), FastMCP, Starlette, Svelte 5 + vitest.

Spec: docs/superpowers/specs/2026-09-30-site-data-design.md — read it before your task; it is the authority.

Global Constraints

  • The feature is called site data; the tool is save_site_data; the open argument is restore_site_data (default true); resources are session://site-data and session://site-data/{site}; the admin tab is Site data.
  • Plain W3C WebDriver only. BiDi is allowed and used only for site data. No CDP.
  • BiDi is reached per call: Grid.bidi(session_id) binds a reattached driver with webSocketUrl = <GRID_URL with http→ws>/session/<id>/se/bidi and closes its socket on exit.
  • Site data lives on SessionRecord.site_data in the session store. Never on disk. Never logged. No new setting; it expires with the record.
  • Size cap: MAX_BYTES = 1_000_000 bytes of json.dumps(site_data).
  • httpOnly cookie values are shown as "•••" on every surface (resource, HTTP, admin). Nothing else is masked.
  • Tab marker: sessionStorage key prefix selenium-flow:restored:; keys with it are never saved.
  • The hint key in results is site_data; it appears only when something happened in that call.
  • Every browser action is both a tool and an endpoint: save_site_data ↔ POST /browser/save-site-data.
  • SessionManager.act is the only place a captured result is merged; the private result key is CAPTURED = "_site_data_captured" and never reaches a caller.
  • House style: docstrings in mcp/tools.py are prompts; comments explain the non-obvious why and are terse; UI copy is terse, descriptions behind ⓘ, no helper sentences beyond the drawn ones.
  • Tests: PYTHONPATH=.:<deps> python3 -S -m pytest -q -m "not integration" (the -S avoids kubed-krm shadowing the kubed namespace in the dev pod). Ruff clean. test_wiki may fail in the pod only because its subprocess lacks -S; python3 -S scripts/generate_wiki.py --check is the real check there.
  • Never run psalm/php tools; never run integration tests in the pod.
  • Commit messages end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

File Structure

File Responsibility
kubed/selenium_flow/core/site_data.py (new) Pure: origin_of, host_of, merge, live_cookies, preload_source, view, site_view, summary, forget, matching_secrets. BiDi-using: capture, restore, retire.
kubed/selenium_flow/core/browser.py Grid._options enables BiDi; Grid.bidi(session_id) context manager.
kubed/selenium_flow/core/actions.py save_site_data, open_session(site_data=...), retire_site_data.
kubed/selenium_flow/session/store.py SessionRecord.site_data, JSON round trip, with_site_data.
kubed/selenium_flow/session/sessions.py merge in act, hint on arrival, announce after reopen, open_browser(restore_site_data), remember keeps site data, describe summary.
kubed/selenium_flow/mcp/tools.py, routes.py, spec/schemas.py, mcp/resources.py The surface.
kubed/selenium_flow/http/admin.py Admin API and session-row fields.
ui/src/admin/SiteDataPane.svelte (new), SessionDetail.svelte, router.svelte.ts, ui/src/lib/types.ts The tab.
skills/selenium-flow/references/SITE_DATA.md (new) + existing references The skill.
tests/test_site_data.py (new), tests/test_site_data_sessions.py (new), tests/test_site_data_surface.py (new), ui/src/admin/SiteDataPane.test.ts (new) Tests.
tests/integration/flows/save-site-data.yaml (new) The one integration flow.

Task 1: The model — pure site-data logic and the record field

Files:

  • Create: kubed/selenium_flow/core/site_data.py
  • Modify: kubed/selenium_flow/session/store.py (SessionRecord)
  • Test: tests/test_site_data.py

Interfaces:

  • Produces (used by every later task):
    • CAPTURED = "_site_data_captured", MARKER = "selenium-flow:restored:", MAX_BYTES = 1_000_000, MASK = "•••"
    • origin_of(url: str) -> str — location.origin form: scheme://host[:port], lowercase host, default ports (http 80, https 443) dropped; "" for anything without a scheme and host.
    • host_of(origin_or_url: str) -> str — lowercase hostname or "".
    • merge(existing: dict, captured: dict, now: float) -> tuple[dict, dict] — returns (new_site_data, saved_summary).
    • live_cookies(cookies: list[dict], now: float) -> list[dict]
    • preload_source(origins: dict[str, dict]) -> str
    • view(site_data: dict, secrets: list[dict] | None = None) -> dict and site_view(site_data: dict, site: str, secrets=None) -> dict | None
    • summary(site_data: dict) -> dict | None — {"sites": n, "uri": "session://site-data"} or None when empty.
    • forget(site_data: dict, host: str) -> tuple[dict, dict]
    • SessionRecord.site_data: dict (default {}), carried by at, reshaped, detached (they use dataclasses.replace, so this is automatic), round-tripped by to_json/from_json, and SessionRecord.with_site_data(site_data: dict) -> SessionRecord.

Cookie dicts in site_data["cookies"] have exactly: name, value, value_type ("string"|"base64"), domain, path, http_only, secure, same_site, expiry (int|None).

captured (what Actions.save_site_data produces, Task 2) is: {"cookies": [cookie dicts], "origin": "<location.origin or ''>", "local": {k: v}, "session": {k: v}}.

  • Step 1: Write the failing tests — tests/test_site_data.py:
"""Site data: what a session saves for the sites it signed in to, and how
it is shown. Pure logic only; BiDi is Task 2's."""

import json

import pytest

from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.session.store import SessionRecord

pytestmark = pytest.mark.unit

NOW = 1_790_800_000.0


def cookie(name, domain, value="v", http_only=False, expiry=None, secure=True):
    return {
        "name": name, "value": value, "value_type": "string", "domain": domain,
        "path": "/", "http_only": http_only, "secure": secure,
        "same_site": "lax", "expiry": expiry,
    }


def captured(origin="https://app.example.com", local=None, session=None, cookies=None):
    return {
        "cookies": cookies if cookies is not None else [cookie("sid", "app.example.com")],
        "origin": origin, "local": local or {}, "session": session or {},
    }


def test_origin_of_matches_location_origin():
    assert sd.origin_of("https://App.Example.com:443/x?y#z") == "https://app.example.com"
    assert sd.origin_of("http://localhost:3000/a") == "http://localhost:3000"
    assert sd.origin_of("http://x.test:80/") == "http://x.test"
    assert sd.origin_of("about:blank") == ""
    assert sd.origin_of("") == ""


def test_a_save_replaces_cookies_and_adds_its_origin():
    first, _ = sd.merge({}, captured(local={"theme": "dark"}), NOW)
    second, saved = sd.merge(
        first,
        captured(origin="https://sso.example.com", cookies=[cookie("kc", "sso.example.com")]),
        NOW + 5,
    )
    assert [c["name"] for c in second["cookies"]] == ["kc"], "cookies are the whole jar, replaced"
    assert set(second["origins"]) == {"https://app.example.com", "https://sso.example.com"}
    assert second["origins"]["https://app.example.com"]["local"] == {"theme": "dark"}
    assert saved == {"cookies": 1, "sites": ["https://sso.example.com"], "skipped": []}


def test_a_page_with_no_origin_saves_cookies_only():
    data, saved = sd.merge({}, captured(origin=""), NOW)
    assert data["origins"] == {}
    assert saved["sites"] == []


def test_the_restore_marker_is_never_saved():
    data, _ = sd.merge(
        {}, captured(session={"selenium-flow:restored:https://app.example.com": "1", "k": "v"}), NOW
    )
    assert data["origins"]["https://app.example.com"]["session"] == {"k": "v"}


def test_a_save_over_the_cap_keeps_cookies_and_skips_that_storage():
    big = {"blob": "x" * (sd.MAX_BYTES + 1)}
    data, saved = sd.merge({}, captured(local=big), NOW)
    assert data["cookies"] and data["origins"] == {}
    assert saved["skipped"] == [
        {"site": "https://app.example.com", "reason": "storage over 1000000 bytes"}
    ]


def test_merge_keeps_pending():
    data, _ = sd.merge({"pending": {"browser": "b", "origins": [], "script": ""}}, captured(), NOW)
    assert data["pending"]["browser"] == "b"


def test_expired_cookies_are_dropped_and_session_cookies_kept():
    kept = sd.live_cookies(
        [cookie("old", "a.test", expiry=int(NOW) - 1), cookie("new", "a.test", expiry=int(NOW) + 60),
         cookie("session", "a.test", expiry=None)],
        NOW,
    )
    assert [c["name"] for c in kept] == ["new", "session"]


def test_the_preload_script_fills_only_its_own_origin_once_per_tab():
    src = sd.preload_source({"https://app.example.com": {"local": {"a": "1"}, "session": {"b": "2"}}})
    assert src.startswith("() =>")
    assert "location.origin" in src
    assert "selenium-flow:restored:" in src
    assert json.dumps({"a": "1"}) in src


def test_the_view_groups_by_host_and_shares_parent_cookies():
    data, _ = sd.merge(
        {},
        captured(
            local={"theme": "dark"},
            cookies=[
                cookie("sid", "app.example.com", value="secret", http_only=True),
                cookie("ab", ".example.com", value="b1"),
                cookie("kc", "sso.example.com"),
            ],
        ),
        NOW,
    )
    listing = sd.view(data)
    sites = {s["site"]: s for s in listing["sites"]}
    assert set(sites) == {"app.example.com", "sso.example.com"}
    assert sites["app.example.com"]["origin"] == "https://app.example.com"
    assert sites["app.example.com"]["cookies"] == 2, "its own and the shared one"
    assert sites["sso.example.com"]["cookies"] == 2
    assert sites["app.example.com"]["uri"] == "session://site-data/app.example.com"
    one = sd.site_view(data, "app.example.com")
    by_name = {c["name"]: c for c in one["cookies"]}
    assert by_name["sid"]["value"] == sd.MASK, "httpOnly is masked"
    assert by_name["ab"]["value"] == "b1" and by_name["ab"]["shared"] is True
    assert one["local_storage"] == {"theme": "dark"}
    assert sd.site_view(data, "nope.test") is None


def test_a_parent_cookie_with_no_host_gets_its_own_site():
    data, _ = sd.merge({}, captured(origin="", cookies=[cookie("ab", ".example.com")]), NOW)
    assert [s["site"] for s in sd.view(data)["sites"]] == ["example.com"]


def test_secrets_are_matched_by_host_and_unleashed_ones_counted():
    secrets = [
        {"name": "app", "description": "d", "keys": ["username", "password"],
         "allowed_urls": ["https://app.example.com"], "restricted": True},
        {"name": "anywhere", "description": "", "keys": ["token"], "allowed_urls": [],
         "restricted": False},
        {"name": "admin", "description": "a", "keys": ["token"],
         "allowed_urls": ["https://admin.example.com"], "restricted": True},
    ]
    data, _ = sd.merge({}, captured(), NOW)
    listing = sd.view(data, secrets)
    sites = {s["site"]: s for s in listing["sites"]}
    assert sites["app.example.com"]["secrets"] == [
        {"name": "app", "description": "d", "keys": ["username", "password"]}
    ]
    assert sites["admin.example.com"]["saved"] is False, "a secret keeps a row with nothing saved"
    assert listing["unleashed_secrets"] == 1
    assert listing["saved_sites"] == 1


def test_forget_keeps_shared_cookies_and_other_sites():
    data, _ = sd.merge(
        {},
        captured(cookies=[cookie("sid", "app.example.com"), cookie("ab", ".example.com"),
                          cookie("kc", "sso.example.com")]),
        NOW,
    )
    left, removed = sd.forget(data, "app.example.com")
    assert [c["name"] for c in left["cookies"]] == ["ab", "kc"]
    assert left["origins"] == {}
    assert removed == {"site": "app.example.com", "cookies": ["sid"], "origins": ["https://app.example.com"],
                       "kept_shared": ["ab"]}


def test_summary_is_none_when_empty():
    assert sd.summary({}) is None
    data, _ = sd.merge({}, captured(), NOW)
    assert sd.summary(data) == {"sites": 1, "uri": "session://site-data"}


def test_the_record_round_trips_its_site_data():
    data, _ = sd.merge({}, captured(), NOW)
    record = SessionRecord(session_id="s", url="u").with_site_data(data)
    again = SessionRecord.from_json(record.to_json())
    assert again.site_data == data
    assert again.detached().site_data == data, "ending a browser keeps site data"
    assert again.at("https://x").site_data == data


def test_a_record_written_before_site_data_reads_as_empty():
    assert SessionRecord.from_json('{"session_id": "s", "url": "u"}').site_data == {}
    assert SessionRecord.from_json('{"session_id": "s", "site_data": [1]}').site_data == {}
  • Step 2: Run to verify it fails

Run: PYTHONPATH=.:$DEPS python3 -S -m pytest -q tests/test_site_data.py Expected: FAIL — ImportError: cannot import name 'site_data'.

  • Step 3: Implement — kubed/selenium_flow/core/site_data.py:
"""Site data: the cookies and storage a session keeps for the sites it uses.

A session outlives its browser, and used to come back signed out: the page it
was on survived a reap, the sign-in did not. An agent now saves a site's data
when it knows it is worth keeping — after a sign-in is confirmed — and every
browser opened for the session gets it back (spec 2026-09-30).

Saved, not captured: every call paying for a snapshot was rejected, and a reap
gives no warning, so only an explicit save is dependable.

**A site is a host.** Cookies carry a domain and no scheme or port, so the view
groups by host. Storage is kept per origin, keyed by the page's own
``location.origin`` because that is the string the preload script compares.

Values are credentials. They live in the session store and nowhere else, are
never logged, and an httpOnly cookie's value is never shown on any surface.
"""

from __future__ import annotations

import json
from urllib.parse import urlsplit

# The private key an action hands its capture back under. `SessionManager.act`
# merges it into the record and removes it; no caller ever sees it.
CAPTURED = "_site_data_captured"
# Marks a tab whose storage was already filled, so a later page on the same
# origin never overwrites what the app changed since. Never saved.
MARKER = "selenium-flow:restored:"
MAX_BYTES = 1_000_000
MASK = "•••"
LIST_URI = "session://site-data"
DEFAULT_PORTS = {"http": 80, "https": 443}


def origin_of(url: str) -> str:
    """``url``'s origin as ``location.origin`` spells it, or "" when it has none."""
    try:
        parts = urlsplit((url or "").strip())
        host = (parts.hostname or "").lower()
        port = parts.port
    except ValueError:
        return ""
    if not parts.scheme or not host:
        return ""
    scheme = parts.scheme.lower()
    shown = f":{port}" if port is not None and port != DEFAULT_PORTS.get(scheme) else ""
    return f"{scheme}://{host}{shown}"


def host_of(url: str) -> str:
    try:
        return (urlsplit((url or "").strip()).hostname or "").lower()
    except ValueError:
        return ""


def site_uri(host: str) -> str:
    return f"{LIST_URI}/{host}"


def merge(existing: dict, captured: dict, now: float) -> tuple[dict, dict]:
    """Fold one save into what the session already had.

    The cookies are the whole jar and replace the last save's; storage is the
    one origin the page was on, added beside the others. A save that would
    pass the cap keeps its cookies and leaves that origin's storage out,
    saying so, rather than failing or truncating.
    """
    data = {
        "cookies": list(captured.get("cookies") or []),
        "origins": dict((existing or {}).get("origins") or {}),
        "saved_at": now,
    }
    if (existing or {}).get("pending"):
        data["pending"] = existing["pending"]
    origin = captured.get("origin") or ""
    sites, skipped = [], []
    if origin:
        entry = {
            "local": dict(captured.get("local") or {}),
            "session": {
                k: v for k, v in (captured.get("session") or {}).items()
                if not k.startswith(MARKER)
            },
            "saved_at": now,
        }
        trial = {**data, "origins": {**data["origins"], origin: entry}}
        if len(json.dumps(trial)) > MAX_BYTES:
            skipped.append({"site": origin, "reason": f"storage over {MAX_BYTES} bytes"})
        else:
            data = trial
            sites.append(origin)
    return data, {"cookies": len(data["cookies"]), "sites": sites, "skipped": skipped}


def live_cookies(cookies: list[dict], now: float) -> list[dict]:
    """Every cookie a restore should set: expired ones dropped, session
    cookies (no expiry) kept, because the session they belong to is ours and
    outlived the browser."""
    return [c for c in cookies if c.get("expiry") is None or c["expiry"] > now]


def preload_source(origins: dict[str, dict]) -> str:
    """The script that fills each origin's storage before the page's own
    scripts run, once per tab. Every origin rides in one script because a
    preload script cannot be scoped to a URL."""
    payload = json.dumps(
        {o: {"local": e.get("local") or {}, "session": e.get("session") or {}}
         for o, e in origins.items()}
    )
    marker = json.dumps(MARKER)
    return (
        "() => { const all = " + payload + "; const o = location.origin; "
        "const s = all[o]; if (!s) return; const m = " + marker + " + o; "
        "try { if (sessionStorage.getItem(m)) return; "
        "for (const [k, v] of Object.entries(s.local)) localStorage.setItem(k, v); "
        "for (const [k, v] of Object.entries(s.session)) sessionStorage.setItem(k, v); "
        "sessionStorage.setItem(m, '1'); } catch (e) {} }"
    )


def _covers(domain: str, host: str) -> bool:
    bare = domain.lstrip(".")
    return host == bare or (domain.startswith(".") and host.endswith(domain))


def _hosts(data: dict) -> list[str]:
    stored = {host_of(o) for o in (data.get("origins") or {})}
    for c in data.get("cookies") or []:
        domain = c.get("domain") or ""
        if domain and not any(_covers(domain, h) for h in stored):
            stored.add(domain.lstrip("."))
    return sorted(h for h in stored if h)


def _cookie_view(c: dict) -> dict:
    return {
        "name": c.get("name"),
        "value": MASK if c.get("http_only") else c.get("value"),
        "domain": c.get("domain"),
        "path": c.get("path"),
        "expiry": c.get("expiry"),
        "http_only": bool(c.get("http_only")),
        "secure": bool(c.get("secure")),
        "same_site": c.get("same_site"),
        "shared": (c.get("domain") or "").startswith("."),
    }


def matching_secrets(secrets: list[dict] | None, host: str) -> list[dict]:
    """The secrets allowed on ``host``, names and keys only. A secret with no
    `allowed_urls` is usable anywhere and is not listed under every site."""
    return [
        {"name": s["name"], "description": s.get("description") or "", "keys": list(s.get("keys") or [])}
        for s in secrets or []
        if any(host_of(u) == host for u in s.get("allowed_urls") or [])
    ]


def _site(data: dict, host: str, secrets) -> dict:
    origins = {o: e for o, e in (data.get("origins") or {}).items() if host_of(o) == host}
    cookies = [c for c in data.get("cookies") or [] if _covers(c.get("domain") or "", host)]
    local, session = {}, {}
    for entry in origins.values():
        local.update(entry.get("local") or {})
        session.update(entry.get("session") or {})
    saved = [e.get("saved_at") for e in origins.values() if e.get("saved_at")]
    return {
        "site": host,
        "origin": next(iter(sorted(origins)), None),
        "saved": bool(origins or cookies),
        "saved_at": max(saved) if saved else (data.get("saved_at") if cookies else None),
        "uri": site_uri(host),
        "_cookies": cookies,
        "_local": local,
        "_session": session,
        "secrets": matching_secrets(secrets, host),
    }


def view(data: dict, secrets: list[dict] | None = None) -> dict:
    """The listing: one entry per site, counts only, never a value.

    With ``secrets`` (the admin catalogue listing), a site a secret is allowed
    on is listed even with nothing saved — secrets are not ephemeral, so their
    rows are not either.
    """
    hosts = set(_hosts(data or {}))
    for s in secrets or []:
        hosts.update(host_of(u) for u in s.get("allowed_urls") or [])
    rows = []
    for host in sorted(h for h in hosts if h):
        site = _site(data or {}, host, secrets)
        rows.append({
            "site": site["site"], "origin": site["origin"], "saved": site["saved"],
            "saved_at": site["saved_at"], "uri": site["uri"],
            "cookies": len(site["_cookies"]), "local_storage": len(site["_local"]),
            "session_storage": len(site["_session"]), "secrets": site["secrets"],
        })
    unleashed = sum(1 for s in secrets or [] if not s.get("allowed_urls"))
    return {
        "sites": rows,
        "saved_sites": sum(1 for r in rows if r["saved"]),
        "unleashed_secrets": unleashed,
        "uri": LIST_URI,
    }


def site_view(data: dict, site: str, secrets: list[dict] | None = None) -> dict | None:
    """One site in full: cookies (httpOnly masked) and both storages."""
    host = (site or "").lower()
    listed = {r["site"] for r in view(data, secrets)["sites"]}
    if host not in listed:
        return None
    s = _site(data or {}, host, secrets)
    return {
        "site": host, "origin": s["origin"], "saved": s["saved"], "saved_at": s["saved_at"],
        "uri": s["uri"], "cookies": [_cookie_view(c) for c in s["_cookies"]],
        "local_storage": s["_local"], "session_storage": s["_session"], "secrets": s["secrets"],
    }


def summary(data: dict) -> dict | None:
    sites = _hosts(data or {})
    return {"sites": len(sites), "uri": LIST_URI} if sites else None


def forget(data: dict, host: str) -> tuple[dict, dict]:
    """Remove one site: its origins and the cookies that are its own. A
    parent-domain cookie stays — other sites use it."""
    host = (host or "").lower()
    cookies = data.get("cookies") or []
    gone = [c for c in cookies if (c.get("domain") or "") == host]
    shared = [c for c in cookies if (c.get("domain") or "").startswith(".") and _covers(c["domain"], host)]
    origins = [o for o in (data.get("origins") or {}) if host_of(o) == host]
    left = {
        **data,
        "cookies": [c for c in cookies if c not in gone],
        "origins": {o: e for o, e in (data.get("origins") or {}).items() if o not in origins},
    }
    return left, {
        "site": host, "cookies": [c["name"] for c in gone], "origins": origins,
        "kept_shared": [c["name"] for c in shared],
    }

Then in kubed/selenium_flow/session/store.py, add the field to SessionRecord after settings:

    # Cookies and storage the session saved (core/site_data.py). Kept with the
    # record so it expires with it, and never on disk.
    site_data: dict = field(default_factory=dict)

read it in from_json (beside settings):

            site_data = data.get("site_data")
            ...
                site_data=site_data if isinstance(site_data, dict) else {},

and add:

    def with_site_data(self, site_data: dict) -> SessionRecord:
        """The same record holding this site data."""
        return replace(self, site_data=dict(site_data or {}))
  • Step 4: Run to verify it passes

Run: PYTHONPATH=.:$DEPS python3 -S -m pytest -q tests/test_site_data.py tests/test_sessions.py Expected: PASS.

  • Step 5: Break it on purpose — make _cookie_view return the raw value for httpOnly; confirm test_the_view_groups_by_host_and_shares_parent_cookies fails; restore.

  • Step 6: Commit — git commit -m "Site data: the model — merge, view, forget, the preload script, and the record field"


Task 2: BiDi — open with it, save through it, restore through it

Files:

  • Modify: kubed/selenium_flow/core/browser.py (Grid._options, new Grid.bidi)
  • Modify: kubed/selenium_flow/core/site_data.py (add capture, restore, retire)
  • Modify: kubed/selenium_flow/core/actions.py (save_site_data, open_session(site_data=None), retire_site_data)
  • Test: tests/test_site_data.py (append)

Interfaces:

  • Consumes: Task 1's merge, live_cookies, preload_source, origin_of, host_of, CAPTURED, MARKER.
  • Produces:
    • Grid.bidi(session_id: str) — a context manager yielding a reattached driver with .storage and .script; the socket is closed on exit. webSocketUrl = self.url with its scheme http→ws (https→wss) + /session/{session_id}/se/bidi.
    • site_data.capture(bidi, driver) -> dict — the captured shape from Task 1. driver is the classic reattached driver on the page; storage is read with one execute_script.
    • site_data.restore(bidi, data: dict, landing_url: str | None, now: float) -> tuple[dict, dict] → (report, pending). report = {"restored": [...hosts/origins], "waiting": [origins], "skipped": [{"cookie": name, "domain": d, "reason": str}]}; pending = {"origins": [...], "script": "<id or ''>"}.
    • site_data.retire(bidi, script: str) -> None — best effort.
    • Actions.save_site_data(session_id: str, url: str | None = None) -> dict → {"url", "title", CAPTURED: captured}.
    • Actions.open_session(..., site_data: dict | None = None) → adds "site_data": report and "_site_data_pending": pending to its result only when site_data had anything.
    • Actions.retire_site_data(session_id: str, script: str) -> None.

Restore rules (from the spec): set every live cookie with storage.set_cookie(PartialCookie(name, BytesValue(value_type, value), domain, path=, http_only=, secure=, same_site=, expiry=)); a failure appends to skipped and continues. If any origin has storage, add one preload script with script.add_preload_script(preload_source(origins)); its id is result["script"] when the result is a dict, else the result. restored = the landing URL's origin first, if it has storage, then every cookie-only host (a host with cookies and no stored origin) sorted; waiting = every other stored origin, sorted. Nothing raises out of restore; a BiDi failure as a whole returns {"restored": [], "waiting": [], "skipped": [{"reason": "<message>"}]} and empty pending.

Capture script (the execute_script body):

READ_STORAGE = (
    "const dump = (s) => { const out = {}; try { for (let i = 0; i < s.length; i++) "
    "{ const k = s.key(i); out[k] = s.getItem(k); } } catch (e) {} return out; }; "
    "return {origin: location.origin === 'null' ? '' : location.origin, "
    "local: dump(localStorage), session: dump(sessionStorage)};"
)

Cookie dicts from BiDi StorageCookie: value_type = c.value.type, value = c.value.value, same_site = c.same_site, expiry = c.expiry.

  • Step 1: Write the failing tests — append to tests/test_site_data.py a fake BiDi driver and these cases:
class FakeStorage:
    def __init__(self, cookies=(), refuse=()):
        self.cookies, self.set, self.refuse = list(cookies), [], set(refuse)

    def get_cookies(self, filter=None, partition=None):
        from types import SimpleNamespace
        return SimpleNamespace(cookies=self.cookies)

    def set_cookie(self, cookie=None, partition=None):
        if cookie.name in self.refuse:
            raise RuntimeError("unable to set cookie")
        self.set.append(cookie)


class FakeScript:
    def __init__(self):
        self.added, self.removed = [], []

    def add_preload_script(self, function_declaration=None, **_):
        self.added.append(function_declaration)
        return {"script": "preload-1"}

    def remove_preload_script(self, script=None):
        self.removed.append(script)


class FakeBidi:
    def __init__(self, **kw):
        self.storage, self.script = FakeStorage(**kw), FakeScript()


def bidi_cookie(name, domain, value="v", http_only=False):
    from types import SimpleNamespace
    return SimpleNamespace(name=name, domain=domain, path="/", http_only=http_only, secure=True,
                           same_site="lax", expiry=None,
                           value=SimpleNamespace(type="string", value=value))


class PageDriver:
    def __init__(self, dump):
        self.dump = dump

    def execute_script(self, script, *args):
        return self.dump


def test_capture_reads_every_cookie_and_this_page_storage():
    bidi = FakeBidi(cookies=[bidi_cookie("sid", "app.example.com", http_only=True),
                             bidi_cookie("kc", "sso.example.com")])
    got = sd.capture(bidi, PageDriver({"origin": "https://app.example.com",
                                       "local": {"a": "1"}, "session": {}}))
    assert [c["name"] for c in got["cookies"]] == ["sid", "kc"]
    assert got["cookies"][0] == {"name": "sid", "value": "v", "value_type": "string",
                                 "domain": "app.example.com", "path": "/", "http_only": True,
                                 "secure": True, "same_site": "lax", "expiry": None}
    assert got["origin"] == "https://app.example.com" and got["local"] == {"a": "1"}


def test_restore_sets_live_cookies_and_one_preload_script():
    data, _ = sd.merge({}, captured(local={"a": "1"},
                                    cookies=[cookie("sid", "app.example.com"),
                                             cookie("kc", "sso.example.com"),
                                             cookie("old", "app.example.com", expiry=int(NOW) - 1)]), NOW)
    data, _ = sd.merge(data, captured(origin="https://other.example.com", local={"b": "2"},
                                      cookies=data["cookies"]), NOW)
    bidi = FakeBidi()
    report, pending = sd.restore(bidi, data, "https://app.example.com/home", NOW)
    assert [c.name for c in bidi.storage.set] == ["sid", "kc"], "expired cookie not replayed"
    assert len(bidi.script.added) == 1
    assert report["restored"] == ["https://app.example.com", "sso.example.com"]
    assert report["waiting"] == ["https://other.example.com"]
    assert pending == {"origins": ["https://other.example.com"], "script": "preload-1"}


def test_a_refused_cookie_is_skipped_not_fatal():
    data, _ = sd.merge({}, captured(cookies=[cookie("bad", "app.example.com"),
                                             cookie("ok", "app.example.com")]), NOW)
    bidi = FakeBidi(refuse={"bad"})
    report, _ = sd.restore(bidi, data, None, NOW)
    assert [c.name for c in bidi.storage.set] == ["ok"]
    assert report["skipped"] == [{"cookie": "bad", "domain": "app.example.com",
                                  "reason": "unable to set cookie"}]


def test_restore_with_no_storage_adds_no_preload_script():
    data, _ = sd.merge({}, captured(origin=""), NOW)
    bidi = FakeBidi()
    _, pending = sd.restore(bidi, data, None, NOW)
    assert bidi.script.added == [] and pending == {"origins": [], "script": ""}


def test_grid_bidi_derives_the_socket_from_the_grid_url():
    from kubed.selenium_flow.core.browser import Grid
    with Grid("https://grid.example:4444/").bidi("abc") as driver:
        assert driver.caps["webSocketUrl"] == "wss://grid.example:4444/session/abc/se/bidi"
        assert driver.session_id == "abc"


def test_every_browser_is_opened_with_bidi():
    from kubed.selenium_flow.core.browser import Grid
    for name in ("chrome", "firefox"):
        assert Grid()._options(name).to_capabilities().get("webSocketUrl") is True

Also append an Actions test using the existing actions fixture pattern from tests/conftest.py — patch actions.grid.bidi with a context manager yielding FakeBidi(...) and actions.grid.reconnect with a driver whose execute_script returns the dump and whose current_url/title are set; assert actions.save_site_data("sid")[sd.CAPTURED]["origin"] == "https://app.example.com" and that the result has url and title. And for open_session: patch actions.grid.open to return a fake driver (session_id, get_window_size, get, current_url, title) and grid.bidi as above; assert result["site_data"]["restored"] and result["_site_data_pending"] appear with site data, and neither key appears when site_data is None or {}; assert the preload script was added before driver.get (record call order in the fake).

  • Step 2: Run to verify they fail — …pytest -q tests/test_site_data.py → AttributeError on capture/restore/bidi.

  • Step 3: Implement

    • Grid._options: after options.set_capability("se:downloadsEnabled", True) add options.enable_bidi = True with a two-line comment: every browser speaks BiDi so a saved site can be restored into it; every other tool still uses classic WebDriver.
    • Grid.bidi: a contextlib.contextmanager method building ReattachDriver(command_executor=self.url, options=self._options()), setting session_id, caps = {"webSocketUrl": <derived>}, yielding it, and in finally closing driver._websocket_connection if set (a comment: Selenium opens the socket lazily on .storage/.script, and closing it is the only cleanup — the browser is not ours to quit).
    • site_data.capture, restore, retire per the rules above, with from selenium.webdriver.common.bidi.storage import BytesValue, CookieFilter, PartialCookie imported inside the functions that need them.
    • Actions.save_site_data(self, session_id, url=None): driver = self._at(session_id, url); with self.grid.bidi(session_id) as bidi: captured = site_data.capture(bidi, driver); return {**browser.page_state(driver), site_data.CAPTURED: captured} (page_state already gives url and title).
    • Actions.open_session(..., site_data=None): after the timeouts and self._moved(...), before if url: driver.get(url): if site_data has cookies or origins, with self.grid.bidi(session_id) as bidi: report, pending = site_data_module.restore(bidi, site_data, url, time.time()), then after building the return dict add "site_data": report and "_site_data_pending": pending. Import the module as from . import site_data as site_data_module to avoid shadowing the parameter.
    • Actions.retire_site_data(self, session_id, script): with self.grid.bidi(session_id) as bidi: site_data.retire(bidi, script); swallow and log at info on failure.
    • Update the open_session docstring in actions.py (one sentence: a session's saved site data is restored here, before the first page loads).
  • Step 4: Run to verify they pass — …pytest -q tests/test_site_data.py tests/test_browser_choice.py tests/test_routes.py

  • Step 5: Break it on purpose — move the restore after driver.get(url); confirm the order test fails; restore.

  • Step 6: Commit — git commit -m "Site data: BiDi on every browser; save captures it, open restores it"


Task 3: The session — merge, announce, delete

Files:

  • Modify: kubed/selenium_flow/session/sessions.py
  • Test: tests/test_site_data_sessions.py (new)

Interfaces:

  • Consumes: Task 1 (merge, origin_of, site_uri, host_of, summary, CAPTURED, SessionRecord.with_site_data), Task 2 (Actions.open_session(site_data=) result keys site_data / _site_data_pending, Actions.retire_site_data).
  • Produces:
    • SessionManager.act: pops CAPTURED from a dict result, merges it into the record (merge(record.site_data, captured, time.time())), and puts "saved": <summary>, "uri": "session://site-data" in the result. After touch, if the record's site_data["pending"] names this browser and the result URL's origin is in pending["origins"], the result gets "site_data": {"restored": [origin], "uri": site_uri(host_of(origin))} and the origin leaves pending; when pending["origins"] becomes empty and pending["script"] is set, call self.actions.retire_site_data(session_id, script) and clear script. If pending["announce"] is true (a silent reopen happened), the result gets "site_data": <the reopen report> (merged with any arrival above) and announce is cleared.
    • SessionManager.open_browser(name, url=None, fresh=False, restore_site_data=True, **wanted): with restore on and saved data present, passes site_data=record.site_data to actions.open_session; stores pending = {"browser": new_id, **result.pop("_site_data_pending"), "announce": False} in the record's site data; returns the site_data report. With restore off and saved data present: opens without it, deletes it (with_site_data({})) and returns "site_data": {"forgotten": <number of sites per summary>}. With nothing saved, no site_data key at all.
    • resolve: the reopen passes site_data=record.site_data when it has any, stores pending with "announce": True and "report": <report>.
    • remember(name, session_id, url="", settings=None) keeps the existing record's site_data (read it before writing the new record).
    • describe: adds "site_data": summary(record.site_data) when not None.

Use as_bool(restore_site_data, True) — callers send JSON by hand.

  • Step 1: Write the failing tests — tests/test_site_data_sessions.py, built on tests/conftest.py's RecordingActions, manager() and named_caller (read them first). Extend RecordingActions locally in the test module (a subclass) so open_session(url=None, site_data=None, **kw) records site_data and, when it is non-empty, returns "site_data": {"restored": ["x"], "waiting": ["https://w.test"], "skipped": []} and "_site_data_pending": {"origins": ["https://w.test"], "script": "p1"}; add save_site_data(session_id, url=None) returning a canned CAPTURED payload with url https://app.example.com/x, and retire_site_data(session_id, script) recording calls. Cases:

    1. test_a_save_is_merged_into_the_record_and_never_returned_raw — m.act(NAMED, lambda s: m.actions.save_site_data(s)) → result has saved and uri, not CAPTURED; m.store.get(NAMED).site_data["origins"] has the origin.
    2. test_open_restores_what_was_saved_and_says_so — save, end_browser, open_browser(NAMED) → the fake received site_data, the result's site_data["waiting"] == ["https://w.test"], record pending["browser"] is the new id.
    3. test_restore_off_deletes_and_says_how_many — save then open_browser(NAMED, restore_site_data=False) → fake received no site data, result {"forgotten": 1}, record site_data == {}.
    4. test_nothing_saved_means_no_hint — fresh open_browser → no site_data key.
    5. test_arriving_on_a_waiting_origin_is_announced_once_and_retires_the_script — after (2), act whose result url is https://w.test/page → site_data == {"restored": ["https://w.test"], "uri": "session://site-data/w.test"}; retire_site_data called with "p1"; a second call on the same url has no site_data.
    6. test_a_quiet_call_carries_no_hint — after (2), an act on https://elsewhere.test → no site_data.
    7. test_a_silent_reopen_restores_and_the_next_result_says_so — save, mark the browser dead in grid.alive, call act → the reopen passed site data, and that call's result carries the reopen report; the next call does not.
    8. test_remember_keeps_site_data — save, open_browser twice → site_data["origins"] survives.
    9. test_describe_summarises_site_data — after a save, m.describe(NAMED)["site_data"] == {"sites": 1, "uri": "session://site-data"}.
    10. test_restore_site_data_accepts_a_string_false — restore_site_data="false" deletes.
  • Step 2: Run to verify they fail.

  • Step 3: Implement per the Interfaces above. Keep act readable: move the post-call site-data handling into a private _site_data_after(name, result) method with a docstring naming the three things it does.

  • Step 4: Run …pytest -q tests/test_site_data_sessions.py tests/test_sessions.py tests/test_routes.py → PASS.

  • Step 5: Break it on purpose — drop the site_data preservation in remember; confirm case 8 fails; restore.

  • Step 6: Commit — git commit -m "Site data: the session merges saves, restores on open and reopen, and says when a site arrives"


Task 4: The surface — tool, endpoint, resources, spec, wiki

Files:

  • Modify: kubed/selenium_flow/mcp/tools.py, kubed/selenium_flow/routes.py, kubed/selenium_flow/spec/schemas.py, kubed/selenium_flow/mcp/resources.py
  • Create: wiki/notes/save_site_data.notes.md; regenerate wiki pages
  • Test: tests/test_site_data_surface.py (new); the standing tests/test_surfaces.py, tests/test_openapi.py, tests/test_wiki.py

Interfaces:

  • Consumes: Tasks 1–3.
  • Produces: tool save_site_data(url: str | None = None) -> dict via run(lambda s: actions.save_site_data(s, url=url)); open_session(..., restore_site_data: bool = True) passed to sessions.open_browser; route ENDPOINTS["save-site-data"] = "save_site_data"; POST /browser reads body.get("restore_site_data", True); resources session://site-data (listing: site_data.view(record.site_data)) and template session://site-data/{site} (site_view, a ValueError naming session://site-data when absent); HTTP GET {prefix}/site-data and GET {prefix}/site-data/{site} answering the same through http/answer.answer with the caller's session name, registered beside the /files tree the same way.

Tool docstrings (prompts, verbatim):

save_site_data:

Save this session's site data: every cookie the browser holds, and the
localStorage and sessionStorage of the page it is on. Call it right after a
sign-in is confirmed, and after changing a setting you want kept; never before
checking you landed, or you keep a failed sign-in.

Every browser opened for this session gets it back, the one that replaces a
reaped browser included, so you come back signed in. Saving replaces the
cookies and adds this page's storage beside other sites'. Values are never
returned; session://site-data lists what is saved.

open_session — add after the fresh sentence:

Saved site data comes back by default; restore_site_data=false opens without it
and deletes it, which is how you start as a new user.

Annotations: save_site_data — hints("Save site data", destructive=False, idempotent=True) (read the helper's signature in mcp/annotations.py and adjust the call to it); not read-only. Update any allow-list in tests/test_surfaces.py only if that test enumerates tools explicitly, and never weaken an assertion.

Spec (spec/schemas.py): a RESPONSES["save_site_data"] via _page(...) with saved (object: cookies integer, sites array of string, skipped array of objects {site, reason}) and uri (string). Add an optional site_data object to the open response and to the shared page response where the builder allows (descriptions: "Present only when site data was restored, is waiting, or was forgotten in this call."). Describe the two GET /site-data routes the way the /files routes are described.

  • Step 1: Write the failing tests — tests/test_site_data_surface.py:
    1. test_save_site_data_is_a_tool_and_an_endpoint — the tool is listed (fastmcp Client(server.mcp)), routes.ENDPOINTS["save-site-data"] == "save_site_data".
    2. test_open_session_takes_restore_site_data — the tool schema has restore_site_data with default true.
    3. test_the_resources_list_and_show_one_site_masking_httponly — put a record with site data (built with Task 1's merge) under the named session in server.sessions.store; read session://site-data and session://site-data/app.example.com through the client; the httpOnly value is "•••"; an unknown site is an error naming session://site-data.
    4. test_the_http_routes_answer_the_same — TestClient(server.mcp.http_app()) GET /site-data and /site-data/app.example.com with the bearer token and ?session=; same bodies as the resources.
    5. test_the_capture_never_leaves_the_server — through the HTTP POST /browser/save-site-data with Actions.save_site_data patched to return a canned capture and SessionManager.resolve patched to a live id, the JSON body has no _site_data_captured key and has saved.
    6. test_current_session_names_site_data — session://current has site_data after a save.
  • Step 2: Run to verify they fail.
  • Step 3: Implement, then regenerate the wiki: PYTHONPATH=.:$DEPS python3 -S scripts/generate_wiki.py and write wiki/notes/save_site_data.notes.md (3 short paragraphs: when to save; what restore does and the site_data hint; the limits — IndexedDB, httpOnly cannot be set from a script, a save after signing out saves you signed out). The wiki is a submodule: commit inside wiki/ first, then the pointer.
  • Step 4: Run …pytest -q tests/test_site_data_surface.py tests/test_surfaces.py tests/test_openapi.py tests/test_resources.py and python3 -S scripts/generate_wiki.py --check → PASS.
  • Step 5: Break it on purpose — remove the CAPTURED pop in act; confirm test 5 fails; restore.
  • Step 6: Commit — wiki submodule commit, then git commit -m "Site data: save_site_data on both surfaces, restore_site_data on open, and session://site-data"

Task 5: The admin API

Files:

  • Modify: kubed/selenium_flow/http/admin.py
  • Test: tests/test_admin_site_data.py (new)

Interfaces:

  • Consumes: Task 1 view, site_view, forget, summary; catalogue.listing()["secrets"] (entries have name, description, keys, allowed_urls, restricted).
  • Produces:
    • GET {prefix}/admin/sessions/{key}/site-data → {"key": key, **site_data.view(record.site_data, secrets), "details": {host: site_view(...) for every listed site}}. secrets is catalogue.listing()["secrets"] or [] when there is no catalogue. A key with no record answers the same shape with nothing saved (secret rows still listed).
    • DELETE {prefix}/admin/sessions/{key}/site-data/{site} → {"forgotten": <forget's removed summary>}; writes record.with_site_data(left) back; 404 {"error": "no saved site data for <site>"} when the site has nothing saved. Clears the host's origins from pending["origins"] too.
    • Every session row in sessions_payload() gains "site_data_count": view(record.site_data)["saved_sites"] (no secrets — the count is saved sites only) and "site_data_rev": json.dumps(<sorted [host, saved_at, cookies] per saved site>).
    • Both routes are @guarded (token), like /admin/sessions/{key}/files.
  • Step 1: Write the failing tests — list shape with a secret-only row; details mask httpOnly; DELETE keeps shared cookies and the secret row, and 404s the second time; unauthenticated GET is 401; site_data_count and site_data_rev change after a DELETE.
  • Step 2–4: red, implement, green (…pytest -q tests/test_admin_site_data.py tests/test_admin_files.py tests/test_files_and_admin.py).
  • Step 5: Break it on purpose — show the raw httpOnly value in details; confirm a test fails; restore.
  • Step 6: Commit — git commit -m "Site data: the admin API — a session's sites with their secrets, and Forget"

Task 6: The Site data tab

Files:

  • Create: ui/src/admin/SiteDataPane.svelte, ui/src/admin/SiteDataPane.test.ts
  • Modify: ui/src/admin/router.svelte.ts (tab 'site-data', hashes.siteData(key) → #/sessions/<key>/site-data), ui/src/admin/SessionDetail.svelte (third subtab + pane), ui/src/lib/types.ts (types for the admin payload), ui/src/admin/router.test.ts

The drawing is the spec for the look: Penpot Admin UI, page Session · Site data, version Site data design, round 1 — secret key pills; boards site-data, site-data-forgotten, site-data-nothing-saved, overlay-confirm-site-forget. Reuse the existing pieces the drawing reuses: the Section accordion rows (as FilesPane uses them), the Settings-style key/value rows, pills, the danger button and the confirm Modal. Read FilesPane.svelte, SettingsPane.svelte, SecretsPane.svelte and Section.svelte first and copy their idioms.

Behaviour:

  • Subtab label Site data with the count from the session row's site_data_count.

  • Loads GET /admin/sessions/{key}/site-data; reloads when the session row's site_data_rev changes (the way Files follows files_rev).

  • One row per sites[] entry: title = origin ?? site; a saved <relative time> pill when saved_at; grey counts N cookies · N local · N session · N secret(s); Forget (danger) only when saved. Nothing saved: counts read nothing saved · N secret.

  • Expanded: COOKIES rows — mono name, value (dots for httpOnly as the API sends them), grey domain · expiry (expiry as the page formats dates elsewhere, session when null), pills httpOnly, secure, and a blue shared; LOCAL STORAGE and SESSION STORAGE rows — mono key, value, none when empty; SECRETS rows — 🔑 name, description, one pill per key name. A row with no saved data shows "Nothing saved. It stays listed because a secret is allowed here." above SECRETS.

  • Above the rows when saved_sites === 0: Nothing saved — an agent calls save_site_data after signing in. with save_site_data in mono.

  • Forget opens the confirm: title Forget site data; body <origin> — a reopened browser comes back signed out here.; a mono list of goes <names…> then one stays line per kept shared cookie and per secret; buttons Cancel / Forget; confirming calls DELETE and reloads.

  • No restore on/off pill.

  • Step 1: Write the failing tests (SiteDataPane.test.ts, vitest + testing-library like SecretsPane.test.ts): rows render in order with counts; httpOnly shows dots; key pills are the key names; the secret-only row has no Forget; the empty-state line shows when saved_sites is 0; Forget → confirm lists goes/stays → DELETE called with the site → reload; the router parses #/sessions/x/site-data.

  • Step 2–4: red, implement, green: npx vitest run and npm run -s check in ui/.

  • Step 5: npm --prefix ui run build succeeds.

  • Step 6: Commit — git commit -m "Site data: the admin tab — sites, their cookies and storage, their secrets, and Forget"


Task 7: The skill, the docs, and the integration flow

Files:

  • Create: skills/selenium-flow/references/SITE_DATA.md, tests/integration/flows/save-site-data.yaml
  • Modify: skills/selenium-flow/SKILL.md, references/SESSIONS.md, references/SECRETS.md, references/FLOWS.md, references/TROUBLESHOOTING.md, AGENTS.md, CHANGELOG.md

Content:

  • SITE_DATA.md — sections: When to save (after a confirmed sign-in: extract/assert, then save_site_data(); after a setting you want kept; never before checking); What comes back (cookies at once, storage on arrival; reading site_data in open_session's result — waiting is normal); Restored but still signed out (the site ended its own session; sign in, then save again); Start as a new user (open_session(restore_site_data=false) deletes); Debugging with execute_script (set/clear a localStorage flag; read document.cookie; compare with session://site-data/{site} for what the page cannot see); Limits (IndexedDB; httpOnly cookies cannot be set by a script; saving replaces all cookies, so a save after signing out saves you signed out). Terse, example-led, in the voice of the other references.

  • SKILL.md — "State carries over" gains "and across a reaped browser, if you saved it"; one row in the "Where to go next" table for SITE_DATA.md; the minimal task example ends with save_site_data() before end_browser(); save_site_data in the tool list.

  • SESSIONS.md — the reap section: a refresh restores saved site data; "cannot restore" keeps only in-page state.

  • SECRETS.md — the sign-in example ends with save_site_data().

  • FLOWS.md — a login flow ends with a save_site_data step.

  • TROUBLESHOOTING.md — "Signed in yesterday, signed out today" → SITE_DATA.md.

  • AGENTS.md — the "Plain W3C WebDriver only" rule names BiDi as W3C and allowed, used only for site data, reattached per call via Grid.bidi; a short "Site data" section: saved explicitly, never captured per call; kept on the record, never on disk; httpOnly never shown; the CAPTURED key is merged in act and never returned.

  • CHANGELOG.md [Unreleased] — one line each: save_site_data keeps a session's cookies and storage so a reopened browser comes back signed in; open_session(restore_site_data=false) starts without it and deletes it; the admin UI has a Site data tab.

  • save-site-data.yaml — same parameters as keep-a-screenshot.yaml; steps: navigate ${admin}/, write the admin token (secret admin/token) into #token, click the login submit, save_site_data, navigate ${admin}/#/sessions/${session}/site-data, assert that the row for the admin origin's host lists the key sf-token under session storage (a script reading the pane's DOM; stable_for: 0.5), with a message saying the saved sign-in did not appear in the Site data tab.

  • Step 1: write the docs and the flow.

  • Step 2: …pytest -q tests/test_skill.py tests/test_readme.py tests/test_guidance.py tests/test_flowdoc.py (the flow document validates) and python3 -S scripts/generate_wiki.py --check.

  • Step 3: Commit — git commit -m "Site data: the skill, the docs, and one integration flow"


Out of the task loop (controller)

  • The full unit suite and ruff before the PR.
  • The live check: run the branch in the pod against the real Grid (as for PR #48), Chrome and Firefox: sign in to a site, save_site_data, end_browser, open_session() → signed in; time the restore; restore_site_data=false → signed out and the tab empty.
  • The PR, and the Copilot review loop.

Clone this wiki locally