Skip to content

feat(website-browser): browser/v1 reads and sets cookies and local storage and returns downloads - #509

Merged
earakely-scale merged 5 commits into
mainfrom
edgararakelyan/fd-4940-m511-browserv1-over-cdp-cookies-local-storage-tabs-and
Nov 6, 2026
Merged

earakely-scale merged 5 commits into
mainfrom
edgararakelyan/fd-4940-m511-browserv1-over-cdp-cookies-local-storage-tabs-and

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

agent-env can now read and set a website browser's cookies and local storage, and read its downloads, through typed browser/v1 operations over CDP. M5.13's data plane (sign-in through load_artifact, browser state in snapshot_env and reset_env) and M5.14's browser task steps are both built on these operations. This is FD-4940 (M5.11). Its outcome and four decisions are recorded there.

The operations are framework-only, like the rest of browser/v1: reached through the computer driver's /svc/chromium/, behind the control token, and never an agent tool. They work the same in a desktop and on the browser computer alone. All of them act on the default context; contexts come with M8.15.

Commit 1, the split. website_browser.py (984 lines) becomes a package:

  • devtools.py, the CDP pipe;
  • control.py, BrowserControl and its record;
  • environment.py, WebsiteBrowserEnvironment and its settings.

The package re-exports every name it had, so no imports change. Git shows it as a delete plus new files; the code moved unchanged. Two tests that patch a module global now patch the submodule that defines it.

Commit 2, the operations and the shapes.

  • Field names (decision 1). Cookies and local storage take the shape of Playwright's storageState, which uses CDP's Network.Cookie names (httpOnly, sameSite, expires), so a storage state exported from Playwright loads as is. agentenv's own records keep the snake_case names main already ships, which the page driver, the relay and the gateway's events use. shapes.py defines them as pydantic models: OpenTab, ClosedTab, Download, Dialog, LaunchEntry and BrowserRecord. Tabs gain opener, the tab that opened a popup. Every field is now present in the record, null until it's known.
  • cookies/get {urls?}, cookies/set {cookies} and cookies/delete {name?, domain?, path?} (decision 4).
    • get returns the cookies a request to any of urls would send, as Playwright picks them, or every cookie.
    • set takes a url, or a domain and path (/ by default). A cookie may carry max_age in place of expires, which becomes an absolute expiry on the computer's clock, the one Chrome judges cookies by. A cookie with both is refused. All the cookies go in one Storage.setCookies call, which Chrome applies all or none of, so a refusal means none was set.
    • delete removes every cookie matching all the given fields, by its exact domain, and says how many.
    • A partitioned cookie keeps its partitionKey, on the way out, on the way back in and to its deletion. Without the key, Chrome makes a second, unpartitioned cookie, and Network.deleteCookies leaves the partitioned one in place.
    • A cookie refused is reported by its fields, never its value.
  • local_storage/get {origins?}, local_storage/set {origins} and local_storage/clear {origins?} (decision 2). CDP reaches an origin's local storage only through a frame of that origin. In Chrome 156, DOMStorage by storage key or origin answers "Frame not found for the given storage id" otherwise. So each operation sends a hidden page of agentenv's own to the origin and answers its request with Fetch.fulfillRequest: the site gets no request and runs no script.
    • The hidden page bypasses the origin's service worker (Network.enable, then Network.setBypassServiceWorker). Otherwise a registered worker answers the navigation itself and runs the site's script there.
    • Each origin is answered as the browser names it once the page is there: https://bücher.test comes back as https://xn--bcher-kva.test.
    • With no origins, it covers every origin a page or same-site frame has loaded since its server started, plus those agentenv set.
    • A cross-site iframe's local storage is partitioned under the site that embeds it, so it isn't read, as Playwright's storageState doesn't read it either.
    • Agents' CDP connections see each hidden page come and go as a target of type other. Hiding them is FD-5277.
  • downloads/list and downloads/get {guid, write_namespace?} (decision 3). get returns a download and, once completed, its file through file_parts, as data/get returns files: uploaded under the caller's write_namespace grant when there is one, else inline. Inline, the whole file is read into memory, as data/get does today, so a large one belongs under a grant. A grant that doesn't validate is reported by its field names only.
  • Docs: the protocol README's website browser section lists the operations and the models. The design's shapes table is updated to match decision 1.

Later commits test the operations under chaos (commit 3) and apply the review's fixes: the service-worker bypass, origins as the browser names them, one setCookies call, and the partition key (commits 4 and 5). Those decisions are on FD-4940.

Not in this PR:

  • hiding the hidden pages from agents (FD-5277);
  • the context argument (M8.15);
  • data/add|get|reset built from these operations (M5.13);
  • the browser task steps (M5.14).

How it was tested

  • ruff check: clean.

  • Unit tests: test_browser_state.py, 12 tests against a stub browser:

    • cookies come back in the storageState shape and are picked for URLs as Playwright picks them (domain, path, secure, local hosts);
    • max_age becomes the expiry on a frozen clock;
    • cookies are set in one call, and a refusal sets none;
    • a partitioned cookie keeps its key when read, set and deleted beside an unpartitioned one of its name;
    • bad input is refused without its value;
    • deletes go by exact domain;
    • local storage goes through a served page that bypasses service workers, and only that page's requests are answered;
    • an origin is kept as the browser names it;
    • with no origins, every origin seen from tabs and same-site frames is used, and URLs that aren't origins are refused;
    • a download comes with its file;
    • the record has the shapes.

    Protocol suite: 1230 passed. Environment, driver, task step and provider unit tests: 6072 passed. The 4 failures there (cua_driver_test and test_disk_recipe) fail the same on main on macOS.

  • VM e2e, agent-environments-admin, amd64 and arm64: browser_state.check_browser_state, a new real-Chrome check, runs on chromium in a desktop (chromium_app_test) and on the browser computer alone (browser_computer_test), against a site served in the computer:

    • a call without the control token is refused;
    • cookies/set with max_age: 3600 gives an expiry within 120 s of an hour after the computer's clock, and an httpOnly cookie is set by domain;
    • local_storage/set before the site is ever visited leaves the site with 0 requests;
    • the page then gets both cookies in its Cookie header, sees only the non-httpOnly one in document.cookie, and reads the item from local storage;
    • local_storage/get with no origins includes the site;
    • cookies/delete {name} deletes 1 and local_storage/clear empties the origin, and the next page load has neither;
    • a partitioned cookie and an unpartitioned one of its name both come back from cookies/get, the first with its key, and cookies/delete {name} deletes both;
    • an origin registers a service worker that answers every navigation with a page writing sw_ran. local_storage/get for it returns no sw_ran, and the site gets no request. A visible tab then loads the worker's page and does get sw_ran, so the worker really was answering;
    • a download comes back from downloads/get with its bytes.

    A chaos test on the browser computer (check_browser_state_under_chaos) checks that:

    • four local_storage/get calls, a cookies/get, a local_storage/set and a local_storage/clear sent at once each get their answer;
    • agentenv's hidden pages never show in the record's tabs or closed tabs;
    • after the app server is killed and supervisord brings it back, the operations work again;
    • with chromium killed, the calls fail within 30 s instead of waiting.

    The cookie and the item set before the server was killed were still there after it, on both arches. The test reports that but doesn't assert it, since it depends on what Chrome had written to its profile.

    Run amd64 arm64
    protocol tests 1228 passed 1228 passed
    chromium, browser computer, website browser and gateway CDP suites, at 84029fe 19 passed, 3 failed 16 passed, 3 failed
    the 3, rerun at d9a7ae7 3 passed 3 passed
    chaos, at d9a7ae7 1 passed 1 passed
    protocol tests, the state checks, chaos and the card test, at 71c7487 1230 + 4 passed 1230 + 4 passed

    The 3 failures in the first run were all in the tests. Two were the new state check: Chrome aborts a navigation that becomes a download (net::ERR_ABORTED), as Playwright's goto does, so the check now starts the download from a page. The third was website_browser_test's exact list of browser/v1 methods, which now has the new ones.

    aee9881 only names a constant. Separately, with Playwright and Puppeteer kept connected to the browser computer's CDP address, the state calls answered in 20–50 ms. Neither client counted agentenv's hidden pages, and both kept driving the browser.

    The full chromium, browser computer, website browser and gateway CDP suites ran too. On arm64, the three echo-agent tests were left out, since their image needs qemu there.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with no new actionable findings.

Summary

Adds framework-only browser/v1 operations for cookies, local storage, and downloaded files.

  • Browser callers can read and set site state and retrieve downloaded files.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Caller[Framework caller] --> Browser["browser/v1"]
  Browser --> Cookies[Cookies through CDP]
  Browser --> Hidden[Hidden page served by agentenv]
  Hidden --> Storage[Local storage]
  Browser --> Downloads[Download record]
  Downloads --> Files[Inline file or granted upload]
Loading

Reviews (3) · Last reviewed commit: "refactor(website-browser): name how ofte..." · Reviewed by Greptile

earakely-scale and others added 3 commits November 5, 2026 13:50
…ipe, its control and its environment

devtools.py holds the CDP pipe, control.py BrowserControl and its record, environment.py WebsiteBrowserEnvironment and
its settings; the package re-exports every name it had, so no import changes. Tests that patch a module global patch it
where it's defined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orage and returns downloads, its record as models

cookies/get|set|delete and local_storage/get|set|clear act on the default context in the shape of Playwright's
storageState; a max_age becomes an expiry on the computer's clock, and local storage goes through a hidden page
agentenv serves itself, so the site gets no request. downloads/list|get return a download with its file, uploaded under
a write_namespace grant or inline. The record's entries are pydantic models, every field present, and tabs name their
opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a download started from the page

Calls at once each get their answer, agentenv's own pages never show in the record, the operations work again after
the app server is killed and comes back, and with the browser killed they fail at once. The download is started from a
page, since a navigation that becomes a download is aborted; the browser card lists the new methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner November 5, 2026 22:44
…rigins as the browser names them, cookies set at once with their partition

The hidden page that reaches an origin's local storage bypasses the origin's service worker, which otherwise answered
its navigation and ran the site's script, and takes the origin as the browser names it once the page is there. Cookies
are set in one call, which the browser applies all or none of, and keep a partitioned cookie's partitionKey on the way
out, in, and to its deletion. A cross-site iframe's partitioned local storage isn't read, as storageState doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… asked whether it reached its origin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit b8cf197 into main Nov 6, 2026
36 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-4940-m511-browserv1-over-cdp-cookies-local-storage-tabs-and branch November 6, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant