Cases / #40 · 2026-10-12 · Easy
Persistent profiles: what a saved browser profile keeps, and what it doesn't
Save a cdpfleet browser profile and restore it: which cookies and storage come back, the newContext() trap, read-only sharing, one writer at a time.
Run on production on 2026-10-12: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Logging in on every run is slow, and on sites with a second factor it can't be automated without the secret. A persistent profile keeps the browser's state on cdpfleet between sessions: launch with "profile": "<name>", and the next launch with the same name starts where the last one ended. But does "where it ended" include everything a page had — session cookies, sessionStorage? We write every kind of browser storage in one session, log into a site that keeps its login in a session cookie, and look at what the next session gets back.
What we used, and why
| What | Why |
|---|---|
"profile": "case40-…" | The launch option. The first launch with a new name creates the profile; it's saved when the session ends, after the browser has exited. Each run uses a fresh name and deletes the profile at the end (DELETE /v1/me/profiles/{name}). |
browser.contexts()[0] | The profile is the browser's existing context. The programs open their pages there, not in browser.newContext(). |
| authenticationtest.com | A public practice login with a TOTP second factor (the code is generated as in the TOTP login case); the account, password and secret are the demo values the site publishes. |
| Five kinds of storage on httpbin.org | A cookie with an expiry (what "Remember me" sets), a cookie without one (a session cookie), localStorage, sessionStorage and an IndexedDB record. |
{ "id": …, "mode": "readonly" } | Read-only launches of the same profile while another session holds it. |
How it works
- Session A launches with a new profile name: log into the practice site (with the TOTP code) and write the five kinds of storage. Close the browser, which ends the session, and wait until
GET /v1/me/profilesshows version 1. - Session B launches with the same name: read the storage back in
contexts()[0], open the practice site, and read the storage in anewContext()too. - While B is open, launch the profile again normally, then three times read-only.
- Close B, wait for version 2, delete the profile.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { createHmac, randomBytes } from 'node:crypto';
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const NAME = `case40-node-${randomBytes(4).toString('hex')}`; // one profile per run
const PRACTICE = 'https://authenticationtest.com';
const SITE = 'https://httpbin.org/html';
// The practice site publishes its test account and TOTP secret on the challenge page.
const LOGIN = { email: '[email protected]', password: 'pa$$w0rd', secret: 'I65VU7K5ZQL7WB4E' };
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
function totp(secret, unixSeconds) {
let bits = '';
for (const c of secret) bits += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(c).toString(2).padStart(5, '0');
const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(unixSeconds / 30)));
const h = createHmac('sha1', key).update(counter).digest();
const o = h[h.length - 1] & 15;
return String((h.readUInt32BE(o) & 0x7fffffff) % 1000000).padStart(6, '0');
}
async function api(method, path) {
const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
return { status: res.status, body: await res.json().catch(() => ({})) };
}
const profileInfo = async () => (await api('GET', '/v1/me/profiles')).body.profiles?.find((p) => p.name === NAME);
// The node saves the profile after the session's browser has exited: wait for the new version.
async function waitForVersion(version) {
for (let i = 0; i < 60; i++) {
const p = await profileInfo();
if (p?.version >= version && !p.in_use) return p;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`profile ${NAME} never reached version ${version}`);
}
// Launch with the profile. Returns the launch status/error too, so a refused launch is data.
async function launch(profile) {
const t = Date.now();
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: true, profile }),
});
const body = await res.json();
if (!res.ok) return { status: res.status, error: body.error };
const browser = await chromium.connect(body.wsUrl, { headers: { 'x-api-key': KEY } });
const context = browser.contexts()[0]; // the profile — a newContext() would not be saved
const page = context.pages()[0] ?? await context.newPage();
return { status: res.status, sessionId: body.sessionId, browser, context, page, launch_ms: Date.now() - t };
}
// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
for (let attempt = 1; ; attempt++) {
try {
return await page.goto(url, { timeout: 60000 });
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
// What the page sees of each kind of storage on httpbin.org.
async function storage(page) {
await go(page, SITE);
return page.evaluate(async () => {
const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
const idb = await new Promise((resolve) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
const get = db.transaction('auth').objectStore('auth').get('user');
get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
get.onerror = () => { db.close(); resolve(null); };
};
open.onerror = () => resolve(null);
});
return {
persistent_cookie: cookies.remember_me === 'alice',
session_cookie: cookies.session_only === 'yes',
local_storage: localStorage.getItem('theme') === 'dark',
session_storage: sessionStorage.getItem('draft') === 'unsaved',
indexeddb: idb?.email === '[email protected]',
};
});
}
async function signedIntoPractice(page) {
await go(page, `${PRACTICE}/`);
return (await page.locator('text=Sign Out').count()) > 0;
}
const out = { profile_name_prefix: 'case40-', sessions: {}, first: {}, saved: {}, second: {}, new_context: {}, second_writer: {}, readonly: [], after: {} };
try {
// Session A: a new profile. Log in (TOTP) and write every kind of storage.
const a = await launch(NAME);
if (!a.browser) throw new Error(`launch A ${a.status} ${a.error}`);
out.sessions.a = a.sessionId;
out.first.launch_ms = a.launch_ms;
try {
await go(a.page, `${PRACTICE}/totpChallenge/`);
if (30 - (Date.now() / 1000) % 30 < 5) await new Promise((r) => setTimeout(r, 5200)); // fresh TOTP window
await a.page.fill('#email', LOGIN.email);
await a.page.fill('#password', LOGIN.password);
await a.page.fill('#totpmfa', totp(LOGIN.secret, Date.now() / 1000));
await Promise.all([a.page.waitForURL(/\/login(Success|Fail)\//, { timeout: 60000 }), a.page.click('input[type=submit]')]);
out.first.practice_login = new URL(a.page.url()).pathname === '/loginSuccess/';
out.first.practice_cookies = (await a.context.cookies(PRACTICE)).map((c) => ({ name: c.name, session: c.expires === -1 }));
out.first.practice_signed_in = await signedIntoPractice(a.page); // the detector session B relies on
await go(a.page, SITE);
await a.page.evaluate(async () => {
document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
localStorage.setItem('theme', 'dark');
sessionStorage.setItem('draft', 'unsaved');
await new Promise((resolve, reject) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const tx = open.result.transaction('auth', 'readwrite');
tx.objectStore('auth').put({ email: '[email protected]', token: 'demo-token' }, 'user');
tx.oncomplete = () => { open.result.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
});
});
out.first.wrote = await storage(a.page);
} finally {
await a.browser.close(); // ends the session; the node saves the profile once the browser has exited
}
const saved = await waitForVersion(1);
out.saved = { version: saved.version, bytes: saved.bytes };
// Session B: the same profile, restored.
const b = await launch(NAME);
if (!b.browser) throw new Error(`launch B ${b.status} ${b.error}`);
out.sessions.b = b.sessionId;
out.second.launch_ms = b.launch_ms;
try {
out.second.restored = await storage(b.page);
out.second.practice_signed_in = await signedIntoPractice(b.page);
// The trap: a new context in the same session starts empty and is never saved.
const scratch = await b.browser.newContext();
out.new_context = await storage(await scratch.newPage());
await scratch.close();
// While B holds the profile: a second normal launch is refused, read-only ones work.
const w = await launch(NAME);
out.second_writer = { status: w.status, error: w.error ?? null };
if (w.browser) await w.browser.close();
// Readers one at a time, each closed before the next: none sits idle, none is left behind.
for (let i = 0; i < 3; i++) {
const r = await launch({ id: NAME, mode: 'readonly' });
if (!r.browser) { out.readonly.push({ status: r.status, error: r.error }); continue; }
try {
const s = await storage(r.page);
out.readonly.push({ status: r.status, persistent_cookie: s.persistent_cookie, local_storage: s.local_storage, indexeddb: s.indexeddb });
} finally {
await r.browser.close();
}
await b.page.evaluate(() => document.title); // keep B active (idle sessions end after 60 s on shared threads)
}
// A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
out.after.version_after_readonly = (await profileInfo())?.version;
} finally {
await b.browser.close();
}
out.after.version_after_b = (await waitForVersion(2)).version;
} finally {
// Delete the profile once no session holds it.
for (let i = 0; i < 60 && (await profileInfo())?.in_use; i++) await new Promise((r) => setTimeout(r, 2000));
const del = await api('DELETE', `/v1/me/profiles/${NAME}`);
out.after.deleted = del.status === 200 && (await profileInfo()) === undefined;
}
console.log(JSON.stringify(out, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import struct
import time
from urllib.parse import urlparse
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
NAME = f"case40-python-{secrets.token_hex(4)}" # one profile per run
PRACTICE = "https://authenticationtest.com"
SITE = "https://httpbin.org/html"
# The practice site publishes its test account and TOTP secret on the challenge page.
LOGIN = {"email": "[email protected]", "password": "pa$$w0rd", "secret": "I65VU7K5ZQL7WB4E"}
# Writes every kind of storage on httpbin.org, and reads it back.
WRITE_JS = """async () => {
document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
localStorage.setItem('theme', 'dark');
sessionStorage.setItem('draft', 'unsaved');
await new Promise((resolve, reject) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const tx = open.result.transaction('auth', 'readwrite');
tx.objectStore('auth').put({ email: '[email protected]', token: 'demo-token' }, 'user');
tx.oncomplete = () => { open.result.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
});
}"""
READ_JS = """async () => {
const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
const idb = await new Promise((resolve) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
const get = db.transaction('auth').objectStore('auth').get('user');
get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
get.onerror = () => { db.close(); resolve(null); };
};
open.onerror = () => resolve(null);
});
return {
persistent_cookie: cookies.remember_me === 'alice',
session_cookie: cookies.session_only === 'yes',
local_storage: localStorage.getItem('theme') === 'dark',
session_storage: sessionStorage.getItem('draft') === 'unsaved',
indexeddb: idb?.email === '[email protected]',
};
}"""
def totp(secret, unix_seconds):
"""RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case."""
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
h = hmac.new(key, struct.pack(">Q", int(unix_seconds // 30)), hashlib.sha1).digest()
o = h[-1] & 15
return "%06d" % ((struct.unpack(">I", h[o:o + 4])[0] & 0x7FFFFFFF) % 1000000)
def api(method, path):
res = requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30)
try:
return res.status_code, res.json()
except ValueError:
return res.status_code, {}
def profile_info():
return next((p for p in api("GET", "/v1/me/profiles")[1].get("profiles", []) if p["name"] == NAME), None)
def wait_for_version(version):
"""The node saves the profile after the session's browser has exited: wait for the new version."""
for _ in range(60):
p = profile_info()
if p and p["version"] >= version and not p["in_use"]:
return p
time.sleep(2)
raise SystemExit(f"profile {NAME} never reached version {version}")
def start(profile):
"""POST the launch with the profile (thread-safe: no Playwright here)."""
t = time.time()
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": True, "profile": profile}, timeout=60)
return res.status_code, res.json(), t
def attach(p, started):
"""Connect to a launched session and open a page in the profile's context."""
status, body, t = started
if status != 200:
return {"status": status, "error": body.get("error")}
browser = p.chromium.connect(body["wsUrl"], headers={"x-api-key": KEY})
context = browser.contexts[0] # the profile — a new_context() would not be saved
page = context.pages[0] if context.pages else context.new_page()
return {"status": status, "sessionId": body["sessionId"], "browser": browser, "context": context, "page": page,
"launch_ms": round((time.time() - t) * 1000)}
def go(page, url):
"""Proxies drop a tunnel now and then: up to two retries."""
for attempt in range(1, 4):
try:
return page.goto(url, timeout=60000)
except Exception:
if attempt == 3:
raise
time.sleep(2)
def storage(page):
go(page, SITE)
return page.evaluate(READ_JS)
def signed_into_practice(page):
go(page, f"{PRACTICE}/")
return page.locator("text=Sign Out").count() > 0
out = {"profile_name_prefix": "case40-", "sessions": {}, "first": {}, "saved": {}, "second": {}, "new_context": {},
"second_writer": {}, "readonly": [], "after": {}}
with sync_playwright() as p:
try:
# Session A: a new profile. Log in (TOTP) and write every kind of storage.
a = attach(p, start(NAME))
if "browser" not in a:
raise SystemExit(f"launch A {a['status']} {a['error']}")
out["sessions"]["a"] = a["sessionId"]
out["first"]["launch_ms"] = a["launch_ms"]
try:
go(a["page"], f"{PRACTICE}/totpChallenge/")
if 30 - time.time() % 30 < 5:
time.sleep(5.2) # fresh TOTP window
a["page"].fill("#email", LOGIN["email"])
a["page"].fill("#password", LOGIN["password"])
a["page"].fill("#totpmfa", totp(LOGIN["secret"], time.time()))
a["page"].click("input[type=submit]")
a["page"].wait_for_url(re.compile(r"/login(Success|Fail)/"), timeout=60000)
out["first"]["practice_login"] = urlparse(a["page"].url).path == "/loginSuccess/"
out["first"]["practice_cookies"] = [{"name": c["name"], "session": c["expires"] == -1} for c in a["context"].cookies(PRACTICE)]
out["first"]["practice_signed_in"] = signed_into_practice(a["page"]) # the detector session B relies on
go(a["page"], SITE)
a["page"].evaluate(WRITE_JS)
out["first"]["wrote"] = storage(a["page"])
finally:
a["browser"].close() # ends the session; the node saves the profile once the browser has exited
saved = wait_for_version(1)
out["saved"] = {"version": saved["version"], "bytes": saved["bytes"]}
# Session B: the same profile, restored.
b = attach(p, start(NAME))
if "browser" not in b:
raise SystemExit(f"launch B {b['status']} {b['error']}")
out["sessions"]["b"] = b["sessionId"]
out["second"]["launch_ms"] = b["launch_ms"]
try:
out["second"]["restored"] = storage(b["page"])
out["second"]["practice_signed_in"] = signed_into_practice(b["page"])
# The trap: a new context in the same session starts empty and is never saved.
scratch = b["browser"].new_context()
out["new_context"] = storage(scratch.new_page())
scratch.close()
# While B holds the profile: a second normal launch is refused, read-only ones work.
w = attach(p, start(NAME))
out["second_writer"] = {"status": w["status"], "error": w.get("error")}
if "browser" in w:
w["browser"].close()
# Readers one at a time, each closed before the next: none sits idle, none is left behind.
for _ in range(3):
r = attach(p, start({"id": NAME, "mode": "readonly"}))
if "browser" not in r:
out["readonly"].append({"status": r["status"], "error": r["error"]})
continue
try:
s = storage(r["page"])
out["readonly"].append({"status": r["status"], "persistent_cookie": s["persistent_cookie"],
"local_storage": s["local_storage"], "indexeddb": s["indexeddb"]})
finally:
r["browser"].close()
b["page"].evaluate("() => document.title") # keep B active (idle sessions end after 60 s on shared threads)
# A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
out["after"]["version_after_readonly"] = (profile_info() or {}).get("version")
finally:
b["browser"].close()
out["after"]["version_after_b"] = wait_for_version(2)["version"]
finally:
# Delete the profile once no session holds it.
for _ in range(60):
info = profile_info()
if not info or not info["in_use"]:
break
time.sleep(2)
status, _ = api("DELETE", f"/v1/me/profiles/{NAME}")
out["after"]["deleted"] = status == 200 and profile_info() is None
print(json.dumps(out, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.*;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String NAME = "case40-java-" + HexFormat.of().formatHex(SecureRandom.getSeed(4)); // one profile per run
static final String PRACTICE = "https://authenticationtest.com";
static final String SITE = "https://httpbin.org/html";
// The practice site publishes its test account and TOTP secret on the challenge page.
static final String EMAIL = "[email protected]", PASSWORD = "pa$$w0rd", SECRET = "I65VU7K5ZQL7WB4E";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new Gson();
// Writes every kind of storage on httpbin.org, and reads it back.
static final String WRITE_JS = """
async () => {
document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
localStorage.setItem('theme', 'dark');
sessionStorage.setItem('draft', 'unsaved');
await new Promise((resolve, reject) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const tx = open.result.transaction('auth', 'readwrite');
tx.objectStore('auth').put({ email: '[email protected]', token: 'demo-token' }, 'user');
tx.oncomplete = () => { open.result.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
});
}
""";
static final String READ_JS = """
async () => {
const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
const idb = await new Promise((resolve) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
const get = db.transaction('auth').objectStore('auth').get('user');
get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
get.onerror = () => { db.close(); resolve(null); };
};
open.onerror = () => resolve(null);
});
return {
persistent_cookie: cookies.remember_me === 'alice',
session_cookie: cookies.session_only === 'yes',
local_storage: localStorage.getItem('theme') === 'dark',
session_storage: sessionStorage.getItem('draft') === 'unsaved',
indexeddb: idb?.email === '[email protected]',
};
}
""";
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
static String totp(String secret, double unixSeconds) throws Exception {
String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
ByteBuffer key = ByteBuffer.allocate(secret.length() * 5 / 8);
int buffer = 0, bits = 0;
for (char c : secret.toCharArray()) {
buffer = (buffer << 5) | alphabet.indexOf(c);
bits += 5;
if (bits >= 8) { key.put((byte) (buffer >> (bits - 8))); bits -= 8; }
}
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key.array(), "HmacSHA1"));
byte[] h = mac.doFinal(ByteBuffer.allocate(8).putLong((long) Math.floor(unixSeconds / 30)).array());
int o = h[h.length - 1] & 15;
return String.format("%06d", (ByteBuffer.wrap(h, o, 4).getInt() & 0x7fffffff) % 1000000);
}
static double now() { return System.currentTimeMillis() / 1000.0; }
static HttpResponse<String> api(String method, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY)
.method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
}
static JsonObject profileInfo() throws Exception {
for (JsonElement p : JsonParser.parseString(api("GET", "/v1/me/profiles").body()).getAsJsonObject().getAsJsonArray("profiles")) {
if (p.getAsJsonObject().get("name").getAsString().equals(NAME)) return p.getAsJsonObject();
}
return null;
}
// The node saves the profile after the session's browser has exited: wait for the new version.
static JsonObject waitForVersion(int version) throws Exception {
for (int i = 0; i < 60; i++) {
JsonObject p = profileInfo();
if (p != null && p.get("version").getAsInt() >= version && !p.get("in_use").getAsBoolean()) return p;
Thread.sleep(2000);
}
throw new RuntimeException("profile " + NAME + " never reached version " + version);
}
record Started(int status, JsonObject body, long t) {}
// POST the launch with the profile (thread-safe: no Playwright here).
static Started start(JsonElement profile) {
try {
JsonObject req = new JsonObject();
req.addProperty("proxy", System.getenv("PROXY_URL"));
req.addProperty("headless", true);
req.add("profile", profile);
long t = System.currentTimeMillis();
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(req.toString())).build(), HttpResponse.BodyHandlers.ofString());
return new Started(res.statusCode(), JsonParser.parseString(res.body()).getAsJsonObject(), t);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
record Attached(int status, String error, String sessionId, Browser browser, BrowserContext context, Page page, long launchMs) {}
// Connect to a launched session and open a page in the profile's context.
static Attached attach(Playwright playwright, Started s) {
if (s.status() != 200) {
JsonElement err = s.body().get("error");
return new Attached(s.status(), err == null || err.isJsonNull() ? null : err.getAsString(), null, null, null, null, 0);
}
Browser browser = playwright.chromium().connect(s.body().get("wsUrl").getAsString(),
new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
BrowserContext context = browser.contexts().get(0); // the profile — a newContext() would not be saved
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
return new Attached(s.status(), null, s.body().get("sessionId").getAsString(), browser, context, page, System.currentTimeMillis() - s.t());
}
// Proxies drop a tunnel now and then: up to two retries.
static void go(Page page, String url) throws InterruptedException {
for (int attempt = 1; ; attempt++) {
try {
page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
return;
} catch (PlaywrightException e) {
if (attempt == 3) throw e;
Thread.sleep(2000);
}
}
}
static JsonObject storage(Page page) throws InterruptedException {
go(page, SITE);
return GSON.toJsonTree(page.evaluate(READ_JS)).getAsJsonObject();
}
static boolean signedIntoPractice(Page page) throws InterruptedException {
go(page, PRACTICE + "/");
return page.locator("text=Sign Out").count() > 0;
}
static JsonObject statusError(Attached a) {
JsonObject o = new JsonObject();
o.addProperty("status", a.status());
if (a.error() == null) o.add("error", JsonNull.INSTANCE); else o.addProperty("error", a.error());
return o;
}
public static void main(String[] args) throws Exception {
JsonObject out = new JsonObject();
out.addProperty("profile_name_prefix", "case40-");
JsonObject sessions = new JsonObject(), first = new JsonObject(), saved = new JsonObject(), second = new JsonObject(), after = new JsonObject();
JsonArray readonly = new JsonArray();
out.add("sessions", sessions);
out.add("first", first);
out.add("saved", saved);
out.add("second", second);
out.add("new_context", new JsonObject());
out.add("second_writer", new JsonObject());
out.add("readonly", readonly);
out.add("after", after);
try (Playwright playwright = Playwright.create()) {
try {
// Session A: a new profile. Log in (TOTP) and write every kind of storage.
Attached a = attach(playwright, start(new JsonPrimitive(NAME)));
if (a.browser() == null) throw new RuntimeException("launch A " + a.status() + " " + a.error());
sessions.addProperty("a", a.sessionId());
first.addProperty("launch_ms", a.launchMs());
try {
go(a.page(), PRACTICE + "/totpChallenge/");
if (30 - now() % 30 < 5) Thread.sleep(5200); // fresh TOTP window
a.page().fill("#email", EMAIL);
a.page().fill("#password", PASSWORD);
a.page().fill("#totpmfa", totp(SECRET, now()));
a.page().click("input[type=submit]");
a.page().waitForURL(Pattern.compile("/login(Success|Fail)/"), new Page.WaitForURLOptions().setTimeout(60000));
first.addProperty("practice_login", URI.create(a.page().url()).getPath().equals("/loginSuccess/"));
JsonArray cookies = new JsonArray();
for (var c : a.context().cookies(PRACTICE)) {
JsonObject entry = new JsonObject();
entry.addProperty("name", c.name);
entry.addProperty("session", c.expires == null || c.expires == -1);
cookies.add(entry);
}
first.add("practice_cookies", cookies);
first.addProperty("practice_signed_in", signedIntoPractice(a.page())); // the detector session B relies on
go(a.page(), SITE);
a.page().evaluate(WRITE_JS);
first.add("wrote", storage(a.page()));
} finally {
a.browser().close(); // ends the session; the node saves the profile once the browser has exited
}
JsonObject s1 = waitForVersion(1);
saved.add("version", s1.get("version"));
saved.add("bytes", s1.get("bytes"));
// Session B: the same profile, restored.
Attached b = attach(playwright, start(new JsonPrimitive(NAME)));
if (b.browser() == null) throw new RuntimeException("launch B " + b.status() + " " + b.error());
sessions.addProperty("b", b.sessionId());
second.addProperty("launch_ms", b.launchMs());
try {
second.add("restored", storage(b.page()));
second.addProperty("practice_signed_in", signedIntoPractice(b.page()));
// The trap: a new context in the same session starts empty and is never saved.
BrowserContext scratch = b.browser().newContext();
out.add("new_context", storage(scratch.newPage()));
scratch.close();
// While B holds the profile: a second normal launch is refused, read-only ones work.
Attached w = attach(playwright, start(new JsonPrimitive(NAME)));
out.add("second_writer", statusError(w));
if (w.browser() != null) w.browser().close();
JsonObject ro = new JsonObject();
ro.addProperty("id", NAME);
ro.addProperty("mode", "readonly");
// Readers one at a time, each closed before the next: none sits idle, none is left behind.
for (int i = 0; i < 3; i++) {
Attached r = attach(playwright, start(ro));
if (r.browser() == null) { readonly.add(statusError(r)); continue; }
try {
JsonObject s = storage(r.page());
JsonObject entry = new JsonObject();
entry.addProperty("status", r.status());
entry.add("persistent_cookie", s.get("persistent_cookie"));
entry.add("local_storage", s.get("local_storage"));
entry.add("indexeddb", s.get("indexeddb"));
readonly.add(entry);
} finally {
r.browser().close();
}
b.page().evaluate("() => document.title"); // keep B active (idle sessions end after 60 s on shared threads)
}
// A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
JsonObject info = profileInfo();
after.add("version_after_readonly", info == null ? JsonNull.INSTANCE : info.get("version"));
} finally {
b.browser().close();
}
after.add("version_after_b", waitForVersion(2).get("version"));
} finally {
// Delete the profile once no session holds it.
for (int i = 0; i < 60; i++) {
JsonObject info = profileInfo();
if (info == null || !info.get("in_use").getAsBoolean()) break;
Thread.sleep(2000);
}
int status = api("DELETE", "/v1/me/profiles/" + NAME).statusCode();
after.addProperty("deleted", status == 200 && profileInfo() == null);
}
}
System.out.println(new GsonBuilder().setPrettyPrinting().create().toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
var name = $"case40-csharp-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant()}"; // one profile per run
const string Practice = "https://authenticationtest.com";
const string Site = "https://httpbin.org/html";
// The practice site publishes its test account and TOTP secret on the challenge page.
const string Email = "[email protected]", Password = "pa$$w0rd", Secret = "I65VU7K5ZQL7WB4E";
string[] storageKeys = ["persistent_cookie", "session_cookie", "local_storage", "session_storage", "indexeddb"];
// Writes every kind of storage on httpbin.org, and reads it back.
const string WriteJs = """
async () => {
document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
localStorage.setItem('theme', 'dark');
sessionStorage.setItem('draft', 'unsaved');
await new Promise((resolve, reject) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const tx = open.result.transaction('auth', 'readwrite');
tx.objectStore('auth').put({ email: '[email protected]', token: 'demo-token' }, 'user');
tx.oncomplete = () => { open.result.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
});
}
""";
const string ReadJs = """
async () => {
const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
const idb = await new Promise((resolve) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
const get = db.transaction('auth').objectStore('auth').get('user');
get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
get.onerror = () => { db.close(); resolve(null); };
};
open.onerror = () => resolve(null);
});
return {
persistent_cookie: cookies.remember_me === 'alice',
session_cookie: cookies.session_only === 'yes',
local_storage: localStorage.getItem('theme') === 'dark',
session_storage: sessionStorage.getItem('draft') === 'unsaved',
indexeddb: idb?.email === '[email protected]',
};
}
""";
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
var output = new JsonObject
{
["profile_name_prefix"] = "case40-", ["sessions"] = new JsonObject(), ["first"] = new JsonObject(), ["saved"] = new JsonObject(),
["second"] = new JsonObject(), ["new_context"] = new JsonObject(), ["second_writer"] = new JsonObject(), ["readonly"] = new JsonArray(), ["after"] = new JsonObject(),
};
var first = output["first"]!.AsObject();
var second = output["second"]!.AsObject();
var after = output["after"]!.AsObject();
var readonlyList = output["readonly"]!.AsArray();
try
{
// Session A: a new profile. Log in (TOTP) and write every kind of storage.
var a = await Attach(await Start(name));
if (a.Browser is null) throw new Exception($"launch A {a.Status} {a.Error}");
output["sessions"]!["a"] = a.SessionId;
first["launch_ms"] = a.LaunchMs;
try
{
await Go(a.Page!, $"{Practice}/totpChallenge/");
if (30 - Now() % 30 < 5) await Task.Delay(5200); // fresh TOTP window
await a.Page!.FillAsync("#email", Email);
await a.Page.FillAsync("#password", Password);
await a.Page.FillAsync("#totpmfa", Totp(Secret, Now()));
await a.Page.ClickAsync("input[type=submit]");
await a.Page.WaitForURLAsync(new Regex("/login(Success|Fail)/"), new() { Timeout = 60000 });
first["practice_login"] = new Uri(a.Page.Url).AbsolutePath == "/loginSuccess/";
first["practice_cookies"] = new JsonArray((await a.Context!.CookiesAsync([Practice]))
.Select(c => (JsonNode)new JsonObject { ["name"] = c.Name, ["session"] = c.Expires == -1 }).ToArray());
first["practice_signed_in"] = await SignedIntoPractice(a.Page); // the detector session B relies on
await Go(a.Page, Site);
await a.Page.EvaluateAsync(WriteJs);
first["wrote"] = await Storage(a.Page);
}
finally
{
await a.Browser.CloseAsync(); // ends the session; the node saves the profile once the browser has exited
}
var saved = await WaitForVersion(1);
output["saved"] = new JsonObject { ["version"] = saved["version"]!.GetValue<int>(), ["bytes"] = saved["bytes"]!.GetValue<long>() };
// Session B: the same profile, restored.
var b = await Attach(await Start(name));
if (b.Browser is null) throw new Exception($"launch B {b.Status} {b.Error}");
output["sessions"]!["b"] = b.SessionId;
second["launch_ms"] = b.LaunchMs;
try
{
second["restored"] = await Storage(b.Page!);
second["practice_signed_in"] = await SignedIntoPractice(b.Page!);
// The trap: a new context in the same session starts empty and is never saved.
var scratch = await b.Browser.NewContextAsync();
output["new_context"] = await Storage(await scratch.NewPageAsync());
await scratch.CloseAsync();
// While B holds the profile: a second normal launch is refused, read-only ones work.
var w = await Attach(await Start(name));
output["second_writer"] = new JsonObject { ["status"] = w.Status, ["error"] = w.Error };
if (w.Browser is not null) await w.Browser.CloseAsync();
// Readers one at a time, each closed before the next: none sits idle, none is left behind.
for (var i = 0; i < 3; i++)
{
var r = await Attach(await Start(new JsonObject { ["id"] = name, ["mode"] = "readonly" }));
if (r.Browser is null) { readonlyList.Add(new JsonObject { ["status"] = r.Status, ["error"] = r.Error }); continue; }
try
{
var s = await Storage(r.Page!);
readonlyList.Add(new JsonObject
{
["status"] = r.Status, ["persistent_cookie"] = s["persistent_cookie"]!.GetValue<bool>(),
["local_storage"] = s["local_storage"]!.GetValue<bool>(), ["indexeddb"] = s["indexeddb"]!.GetValue<bool>(),
});
}
finally
{
await r.Browser.CloseAsync();
}
await b.Page!.EvaluateAsync("() => document.title"); // keep B active (idle sessions end after 60 s on shared threads)
}
// A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
after["version_after_readonly"] = (await ProfileInfo())?["version"]?.GetValue<int>();
}
finally
{
await b.Browser.CloseAsync();
}
after["version_after_b"] = (await WaitForVersion(2))["version"]!.GetValue<int>();
}
finally
{
// Delete the profile once no session holds it.
for (var i = 0; i < 60 && (await ProfileInfo())?["in_use"]?.GetValue<bool>() == true; i++) await Task.Delay(2000);
var del = await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
after["deleted"] = (int)del.StatusCode == 200 && await ProfileInfo() is null;
}
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
static string Totp(string secret, double unixSeconds)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
var key = new List<byte>();
int buffer = 0, bits = 0;
foreach (var c in secret)
{
buffer = (buffer << 5) | alphabet.IndexOf(c);
bits += 5;
if (bits >= 8) { key.Add((byte)(buffer >> (bits - 8))); bits -= 8; }
}
var counter = new byte[8];
BinaryPrimitives.WriteInt64BigEndian(counter, (long)Math.Floor(unixSeconds / 30));
var h = HMACSHA1.HashData(key.ToArray(), counter);
var o = h[^1] & 15;
return ((BinaryPrimitives.ReadInt32BigEndian(h.AsSpan(o, 4)) & 0x7fffffff) % 1000000).ToString("D6");
}
static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
async Task<JsonNode?> ProfileInfo()
{
var list = await http.GetFromJsonAsync<JsonObject>("https://cdpfleet.com/v1/me/profiles");
return list!["profiles"]!.AsArray().FirstOrDefault(p => p!["name"]!.GetValue<string>() == name);
}
// The node saves the profile after the session's browser has exited: wait for the new version.
async Task<JsonNode> WaitForVersion(int version)
{
for (var i = 0; i < 60; i++)
{
var p = await ProfileInfo();
if (p is not null && p["version"]!.GetValue<int>() >= version && !p["in_use"]!.GetValue<bool>()) return p;
await Task.Delay(2000);
}
throw new Exception($"profile {name} never reached version {version}");
}
// POST the launch with the profile.
async Task<(int Status, JsonObject Body, Stopwatch Clock)> Start(JsonNode profile)
{
var clock = Stopwatch.StartNew();
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = true, ["profile"] = profile });
return ((int)res.StatusCode, (await res.Content.ReadFromJsonAsync<JsonObject>())!, clock);
}
// Connect to a launched session and open a page in the profile's context.
async Task<Attached> Attach((int Status, JsonObject Body, Stopwatch Clock) s)
{
if (s.Status != 200) return new Attached(s.Status, s.Body["error"]?.GetValue<string>(), null, null, null, null, 0);
var browser = await playwright.Chromium.ConnectAsync(s.Body["wsUrl"]!.GetValue<string>(),
new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
var context = browser.Contexts[0]; // the profile — a NewContextAsync() would not be saved
var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
return new Attached(s.Status, null, s.Body["sessionId"]!.GetValue<string>(), browser, context, page, s.Clock.ElapsedMilliseconds);
}
// Proxies drop a tunnel now and then: up to two retries.
static async Task Go(IPage page, string url)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
catch (Exception e) when (attempt < 3 && e is PlaywrightException or TimeoutException) { await Task.Delay(2000); }
}
}
// Read by key: Playwright .NET adds "$id" metadata to objects it returns.
async Task<JsonObject> Storage(IPage page)
{
await Go(page, Site);
var el = await page.EvaluateAsync<JsonElement>(ReadJs);
var o = new JsonObject();
foreach (var k in storageKeys) o[k] = el.GetProperty(k).GetBoolean();
return o;
}
static async Task<bool> SignedIntoPractice(IPage page)
{
await Go(page, $"{Practice}/");
return await page.Locator("text=Sign Out").CountAsync() > 0;
}
record Attached(int Status, string? Error, string? SessionId, IBrowser? Browser, IBrowserContext? Context, IPage? Page, long LaunchMs);
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"net/url"
"os"
"regexp"
"time"
"github.com/playwright-community/playwright-go"
)
const practice = "https://authenticationtest.com"
const site = "https://httpbin.org/html"
// The practice site publishes its test account and TOTP secret on the challenge page.
const email, password, secret = "[email protected]", "pa$$w0rd", "I65VU7K5ZQL7WB4E"
// Writes every kind of storage on httpbin.org, and reads it back.
const writeJS = `async () => {
document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
localStorage.setItem('theme', 'dark');
sessionStorage.setItem('draft', 'unsaved');
await new Promise((resolve, reject) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const tx = open.result.transaction('auth', 'readwrite');
tx.objectStore('auth').put({ email: '[email protected]', token: 'demo-token' }, 'user');
tx.oncomplete = () => { open.result.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
});
}`
const readJS = `async () => {
const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
const idb = await new Promise((resolve) => {
const open = indexedDB.open('app', 1);
open.onupgradeneeded = () => open.result.createObjectStore('auth');
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
const get = db.transaction('auth').objectStore('auth').get('user');
get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
get.onerror = () => { db.close(); resolve(null); };
};
open.onerror = () => resolve(null);
});
return {
persistent_cookie: cookies.remember_me === 'alice',
session_cookie: cookies.session_only === 'yes',
local_storage: localStorage.getItem('theme') === 'dark',
session_storage: sessionStorage.getItem('draft') === 'unsaved',
indexeddb: idb?.email === '[email protected]',
};
}`
var key = os.Getenv("CDPFLEET_API_KEY")
var name = func() string { b := make([]byte, 4); rand.Read(b); return "case40-go-" + hex.EncodeToString(b) }() // one profile per run
type Storage struct {
PersistentCookie bool `json:"persistent_cookie"`
SessionCookie bool `json:"session_cookie"`
LocalStorage bool `json:"local_storage"`
SessionStorage bool `json:"session_storage"`
IndexedDB bool `json:"indexeddb"`
}
type ReadonlyEntry struct {
Status int `json:"status"`
Error string `json:"error,omitempty"`
PersistentCookie *bool `json:"persistent_cookie,omitempty"`
LocalStorage *bool `json:"local_storage,omitempty"`
IndexedDB *bool `json:"indexeddb,omitempty"`
}
type PracticeCookie struct {
Name string `json:"name"`
Session bool `json:"session"`
}
type Output struct {
ProfileNamePrefix string `json:"profile_name_prefix"`
Sessions struct {
A string `json:"a,omitempty"`
B string `json:"b,omitempty"`
} `json:"sessions"`
First struct {
LaunchMs int64 `json:"launch_ms"`
PracticeLogin bool `json:"practice_login"`
PracticeCookies []PracticeCookie `json:"practice_cookies"`
PracticeSignedIn bool `json:"practice_signed_in"`
Wrote *Storage `json:"wrote,omitempty"`
} `json:"first"`
Saved struct {
Version int `json:"version"`
Bytes int64 `json:"bytes"`
} `json:"saved"`
Second struct {
LaunchMs int64 `json:"launch_ms"`
Restored *Storage `json:"restored,omitempty"`
PracticeSignedIn bool `json:"practice_signed_in"`
} `json:"second"`
NewContext *Storage `json:"new_context"`
SecondWriter struct {
Status int `json:"status"`
Error *string `json:"error"`
} `json:"second_writer"`
Readonly []ReadonlyEntry `json:"readonly"`
After struct {
VersionAfterReadonly *int `json:"version_after_readonly,omitempty"`
VersionAfterB *int `json:"version_after_b,omitempty"`
Deleted bool `json:"deleted"`
} `json:"after"`
}
type Profile struct {
Name string `json:"name"`
Version int `json:"version"`
Bytes int64 `json:"bytes"`
InUse bool `json:"in_use"`
}
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
func totp(k []byte, unixSeconds float64) string {
counter := make([]byte, 8)
binary.BigEndian.PutUint64(counter, uint64(math.Floor(unixSeconds/30)))
mac := hmac.New(sha1.New, k)
mac.Write(counter)
h := mac.Sum(nil)
o := h[len(h)-1] & 15
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(h[o:o+4])&0x7fffffff)%1000000)
}
func now() float64 { return float64(time.Now().UnixMilli()) / 1000 }
func profileInfo() (*Profile, error) {
req, _ := http.NewRequest("GET", "https://cdpfleet.com/v1/me/profiles", nil)
req.Header.Set("x-api-key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var list struct {
Profiles []Profile `json:"profiles"`
}
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
return nil, err
}
for _, p := range list.Profiles {
if p.Name == name {
return &p, nil
}
}
return nil, nil
}
// The node saves the profile after the session's browser has exited: wait for the new version.
func waitForVersion(version int) (*Profile, error) {
for i := 0; i < 60; i++ {
p, err := profileInfo()
if err == nil && p != nil && p.Version >= version && !p.InUse {
return p, nil
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("profile %s never reached version %d", name, version)
}
type Started struct {
Status int
Body map[string]any
T time.Time
}
// POST the launch with the profile (safe from goroutines: no Playwright here).
func start(profile any) Started {
t := time.Now()
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "profile": profile})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return Started{Status: 0, Body: map[string]any{"error": err.Error()}, T: t}
}
defer res.Body.Close()
var b map[string]any
json.NewDecoder(res.Body).Decode(&b)
return Started{Status: res.StatusCode, Body: b, T: t}
}
type Attached struct {
Status int
Error string
SessionID string
Browser playwright.Browser
Context playwright.BrowserContext
Page playwright.Page
LaunchMs int64
}
// Connect to a launched session and open a page in the profile's context.
func attach(pw *playwright.Playwright, s Started) (*Attached, error) {
if s.Status != 200 {
e, _ := s.Body["error"].(string)
return &Attached{Status: s.Status, Error: e}, nil
}
browser, err := pw.Chromium.Connect(s.Body["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return nil, err
}
context := browser.Contexts()[0] // the profile — a NewContext() would not be saved
var page playwright.Page
if pages := context.Pages(); len(pages) > 0 {
page = pages[0]
} else if page, err = context.NewPage(); err != nil {
browser.Close()
return nil, err
}
return &Attached{Status: s.Status, SessionID: s.Body["sessionId"].(string), Browser: browser, Context: context, Page: page, LaunchMs: time.Since(s.T).Milliseconds()}, nil
}
// Proxies drop a tunnel now and then: up to two retries.
func gotoURL(page playwright.Page, u string) error {
var err error
for attempt := 1; attempt <= 3; attempt++ {
if _, err = page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return err
}
func storage(page playwright.Page) (*Storage, error) {
if err := gotoURL(page, site); err != nil {
return nil, err
}
v, err := page.Evaluate(readJS)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(v)
var s Storage
return &s, json.Unmarshal(raw, &s)
}
func signedIntoPractice(page playwright.Page) (bool, error) {
if err := gotoURL(page, practice+"/"); err != nil {
return false, err
}
n, err := page.Locator("text=Sign Out").Count()
return n > 0, err
}
func sessionA(pw *playwright.Playwright, out *Output, secretKey []byte) error {
a, err := attach(pw, start(name))
if err != nil {
return err
}
if a.Browser == nil {
return fmt.Errorf("launch A %d %s", a.Status, a.Error)
}
defer a.Browser.Close() // ends the session; the node saves the profile once the browser has exited
out.Sessions.A = a.SessionID
out.First.LaunchMs = a.LaunchMs
if err := gotoURL(a.Page, practice+"/totpChallenge/"); err != nil {
return err
}
if 30-math.Mod(now(), 30) < 5 {
time.Sleep(5200 * time.Millisecond) // fresh TOTP window
}
for sel, v := range map[string]string{"#email": email, "#password": password, "#totpmfa": totp(secretKey, now())} {
if err := a.Page.Fill(sel, v); err != nil {
return err
}
}
if err := a.Page.Click("input[type=submit]"); err != nil {
return err
}
// playwright-go 0.6000's WaitForURL panics in a context the client didn't create (it reads
// the context's options, which are nil for contexts()[0]), so poll the URL instead.
landedRe := regexp.MustCompile(`/login(Success|Fail)/`)
for deadline := time.Now().Add(60 * time.Second); !landedRe.MatchString(a.Page.URL()); time.Sleep(200 * time.Millisecond) {
if time.Now().After(deadline) {
return fmt.Errorf("login result page never loaded (at %s)", a.Page.URL())
}
}
landed, _ := url.Parse(a.Page.URL())
out.First.PracticeLogin = landed.Path == "/loginSuccess/"
cookies, err := a.Context.Cookies(practice)
if err != nil {
return err
}
out.First.PracticeCookies = []PracticeCookie{}
for _, c := range cookies {
out.First.PracticeCookies = append(out.First.PracticeCookies, PracticeCookie{Name: c.Name, Session: c.Expires == -1})
}
// The detector session B relies on.
if out.First.PracticeSignedIn, err = signedIntoPractice(a.Page); err != nil {
return err
}
if err := gotoURL(a.Page, site); err != nil {
return err
}
if _, err := a.Page.Evaluate(writeJS); err != nil {
return err
}
out.First.Wrote, err = storage(a.Page)
return err
}
func sessionB(pw *playwright.Playwright, out *Output) error {
b, err := attach(pw, start(name))
if err != nil {
return err
}
if b.Browser == nil {
return fmt.Errorf("launch B %d %s", b.Status, b.Error)
}
defer b.Browser.Close()
out.Sessions.B = b.SessionID
out.Second.LaunchMs = b.LaunchMs
if out.Second.Restored, err = storage(b.Page); err != nil {
return err
}
if out.Second.PracticeSignedIn, err = signedIntoPractice(b.Page); err != nil {
return err
}
// The trap: a new context in the same session starts empty and is never saved.
scratch, err := b.Browser.NewContext()
if err != nil {
return err
}
page, err := scratch.NewPage()
if err != nil {
return err
}
if out.NewContext, err = storage(page); err != nil {
return err
}
scratch.Close()
// While B holds the profile: a second normal launch is refused, read-only ones work.
w, err := attach(pw, start(name))
if err != nil {
return err
}
out.SecondWriter.Status = w.Status
if w.Error != "" {
out.SecondWriter.Error = &w.Error
}
if w.Browser != nil {
w.Browser.Close()
}
// Readers one at a time, each closed before the next: none sits idle, none is left behind.
out.Readonly = []ReadonlyEntry{}
for i := 0; i < 3; i++ {
r, err := attach(pw, start(map[string]string{"id": name, "mode": "readonly"}))
if err != nil {
return err
}
if r.Browser == nil {
out.Readonly = append(out.Readonly, ReadonlyEntry{Status: r.Status, Error: r.Error})
continue
}
st, err := storage(r.Page)
r.Browser.Close()
if err != nil {
return err
}
out.Readonly = append(out.Readonly, ReadonlyEntry{Status: r.Status, PersistentCookie: &st.PersistentCookie, LocalStorage: &st.LocalStorage, IndexedDB: &st.IndexedDB})
if _, err := b.Page.Evaluate("() => document.title"); err != nil { // keep B active (idle sessions end after 60 s on shared threads)
return err
}
}
// A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
if p, err := profileInfo(); err == nil && p != nil {
out.After.VersionAfterReadonly = &p.Version
}
return nil
}
func run(out *Output) error {
secretKey, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return fmt.Errorf("TOTP secret: %w", err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
defer func() {
// Delete the profile once no session holds it.
for i := 0; i < 60; i++ {
if p, err := profileInfo(); err != nil || p == nil || !p.InUse {
break
}
time.Sleep(2 * time.Second)
}
req, _ := http.NewRequest("DELETE", "https://cdpfleet.com/v1/me/profiles/"+name, nil)
req.Header.Set("x-api-key", key)
if res, err := http.DefaultClient.Do(req); err == nil {
res.Body.Close()
p, perr := profileInfo()
out.After.Deleted = res.StatusCode == 200 && perr == nil && p == nil
}
}()
if err := sessionA(pw, out, secretKey); err != nil {
return err
}
saved, err := waitForVersion(1)
if err != nil {
return err
}
out.Saved.Version, out.Saved.Bytes = saved.Version, saved.Bytes
if err := sessionB(pw, out); err != nil { // B's browser is closed when it returns
return err
}
after, err := waitForVersion(2)
if err != nil {
return err
}
out.After.VersionAfterB = &after.Version
return nil
}
func main() {
out := Output{ProfileNamePrefix: "case40-"}
if err := run(&out); err != nil {
log.Fatal(err)
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
What we got
| Storage | Session A wrote | Session B (profile) | Session B, newContext() |
|---|---|---|---|
| Cookie with an expiry ("Remember me") | yes | kept | empty |
| Session cookie (no expiry) | yes | kept | empty |
| localStorage | yes | kept | empty |
| sessionStorage | yes | gone | empty |
| IndexedDB | yes | kept | empty |
| Practice-site login (session cookie) | signed in | signed in |
From the Node.js run on 2026-10-12. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"profile_name_prefix": "case40-",
"sessions": {
"a": "0e01f4ee-9ba5-40ae-a96c-3e7452a7032c",
"b": "00aa7cd4-661c-456a-89ae-8115ee41ace7"
},
"first": {
"launch_ms": 890,
"practice_login": true,
"practice_cookies": [
{
"name": "PHPSESSID",
"session": true
}
],
"practice_signed_in": true,
"wrote": {
"persistent_cookie": true,
"session_cookie": true,
"local_storage": true,
"session_storage": true,
"indexeddb": true
}
},
"saved": {
"version": 1,
"bytes": 4922
},
"second": {
"launch_ms": 646,
"restored": {
"persistent_cookie": true,
"session_cookie": true,
"local_storage": true,
"session_storage": false,
"indexeddb": true
},
"practice_signed_in": true
},
"new_context": {
"persistent_cookie": false,
"session_cookie": false,
"local_storage": false,
"session_storage": false,
"indexeddb": false
},
"second_writer": {
"status": 409,
"error": "profile_in_use"
},
"readonly": [
{
"status": 200,
"persistent_cookie": true,
"local_storage": true,
"indexeddb": true
},
{
"status": 200,
"persistent_cookie": true,
"local_storage": true,
"indexeddb": true
},
{
"status": 200,
"persistent_cookie": true,
"local_storage": true,
"indexeddb": true
}
],
"after": {
"version_after_readonly": 1,
"version_after_b": 2,
"deleted": true
}
}Takeaways
- What came back: the cookie with an expiry, the session cookie, localStorage and the IndexedDB record — in every run and every language. Session cookies are kept too: the profile captures them before the browser closes and puts them back on restore, which a plain browser restart wouldn't do.
- Logins in session cookies survive. The practice site keeps its login in
PHPSESSID, a cookie with no expiry; session B opened the site already signed in, past the TOTP second factor — in all five runs. The login lasts as long as the site keeps the server-side session; when it ends it, you log in again and the next save keeps the new one. - What didn't come back: sessionStorage. It belongs to a tab, not to the browser, and isn't part of a profile. Keep anything that must outlive the session in localStorage, cookies or IndexedDB.
- Work in
browser.contexts()[0]. AnewContext()in the same session started empty — no cookies, no localStorage, no IndexedDB — and, per the docs, nothing in it is saved. This is the mistake to avoid when moving existing code to profiles. (Go: playwright-go 0.6000'sWaitForURLpanics on pages incontexts()[0], a context the client didn't create, so the Go program pollspage.URL()instead.) - One writer, many readers. While session B held the profile, a second normal launch got
409 profile_in_use; three read-only launches all got the saved state, and the profile stayed at version 1 until B ended and saved version 2. - Small and quick: the saved profile was 4.9–5.0 KB across the five runs (caches are left out), and a launch with a profile — new or restored — took 0.6–1.1 s from the launch call to a connected page (one outlier: 3.8 s).
- Profiles or
storageState()? Both now keep session cookies. Profiles keep the state on cdpfleet and need no file handling;storageState()(TOTP login, persisting logins) keeps it in your hands and works on every browser.