diff --git a/README.md b/README.md index 4d3a3aa..60ddcf3 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,50 @@ async with await client.rent(schedule_id) as browser: - Encrypt the blob before writing to disk if it contains sensitive credentials. - `import_()` raises `ValueError` on `schema_version` mismatch (future-proofing). +## Browser Vault (server-stored sessions) + +The **vault** stores a browser snapshot (cookies + per-origin localStorage/sessionStorage + fingerprint) on the Ceki API (`/api/vault/sessions`, encrypted server-side) so you can restore it on a **different** browser later — the same session profile across machines. + +Two surfaces: + +**`client.vault`** — HTTP CRUD on vault sessions (no live browser needed): + +```python +# List your vault sessions +sessions = await client.vault.list() +for s in sessions: + print(s.id, s.label, s.urls) + +# Fetch one session with its decrypted profile envelope +session = await client.vault.get(8) +data = session.data # {cookies, localStorage, sessionStorage, fingerprint, urls, collectedAt} +``` + +**`browser.vault`** — snapshot save / restore from a rented browser: + +```python +# 1. On browser A — export the current state into a NEW vault session +vault_id = await browser.vault.save(label="vc.ru session") + +# or overwrite the session you rented with (browser was rented with vault=8) +vault_id = await browser.vault.save() # PUT onto the bound id + +# 2. On browser B (or the same one later) — rent WITH the vault profile +async with await client.rent(schedule_id, vault=vault_id) as browser: + # cookies applied immediately, localStorage/sessionStorage buffered by the + # extension and flushed on first navigation to each origin + await browser.send({"method": "Page.navigate", "params": {"url": "https://vc.ru"}}) + +# or restore the profile mid-session +await browser.vault.restore(vault_id) +``` + +Notes: +- Vault restore uses `session.configure(profile=...)` — the extension applies cookies first, then buffers localStorage/sessionStorage until the first navigation to each origin (Vault 3+ extension required). +- When `vault=` is passed to `rent()`, the browser is bound to that vault session: a later `browser.vault.save()` overwrites it (PUT). +- The vault endpoints are guarded by Sanctum and resolve to a **user**; use a user token as `api_key` for vault operations. +- `browser.profile.export()` (local blob) and `browser.vault.save()` (server) are complementary: the first keeps the blob agent-side, the second stores it encrypted on the API. + ## CDP Lifecycle The relay maintains the CDP connection to the incognito browser tab. If the connection drops, it automatically reattaches with 1s/2s/4s exponential backoff. Commands during reattach are buffered (FIFO, max 50). If 3 reattach attempts fail, a new fallback tab is created. If that also fails, `cdp_unrecoverable` error is sent. @@ -299,7 +343,7 @@ The CLI persists session state locally — after `rent` it saves the session ID |---|---| | `search [--limit N] [--filter K=V]…` | List available browsers | | `my-browsers` | List browsers with pre-arranged rent contracts | -| `rent --schedule ID [--mode incognito\|main] [--fingerprint-from FILE]` | Rent a browser | +| `rent --schedule ID [--mode incognito\|main] [--fingerprint-from FILE] [--vault SESSION_ID]` | Rent a browser | | `sessions [--all] [--limit N] [--json]` | List your sessions | | `stop SID` | End a session | | `wait SID` | Block until the session ends | @@ -336,6 +380,20 @@ The CLI persists session state locally — after `rent` it saves the session ID | `configure SID [--masking-mode VAL] [--fingerprint VAL]` | Toggle masking / fingerprint | | `cdp SID --method METHOD [--params JSON]` | Raw CDP command | +#### Vault sessions + +| Command | Description | +|---|---| +| `vault list [--json] [--per-page N]` | List vault sessions (id, label, urls, updated) | +| `vault get ID [--json] [-o FILE]` | Show a session; `--json` prints the decrypted profile, `-o` dumps it to a file | +| `vault save FILE [--id ID] [--label L]` | Create (or PUT-update with `--id`) a session from a profile JSON | +| `vault save --session SID [--id ID] [--label L] [--no-session-storage]` | Snapshot a live rental session into the vault | +| `vault apply ID --session SID` | Apply a vault profile into an existing rental (resume + restore) | +| `vault apply ID --schedule N` | Rent a fresh browser with the vault profile restored | +| `vault delete ID` | Delete a vault session | + +Vault commands run over plain HTTP (no relay session) and are user/Sanctum-scoped — the same token caveat as `client.vault` applies (use a user token). + ### Output and errors Successful commands write a single JSON line to stdout. Errors go to stderr as `{"error": "...", "code": "..."}`. Pipe stdout through `jq` to chain commands. diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 5bf1e16..420b24e 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -20,9 +20,10 @@ from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot from ._profile import BrowserProfile from ._provider import ProviderError, run_provider +from ._vault import BrowserVault, ClientVault, VaultSession from .humanize import HumanProfile -__version__ = "2.37.0" +__version__ = "2.37.1" __all__ = [ "connect", "ConnectOptions", @@ -47,6 +48,9 @@ "SessionInfo", "Snapshot", "BrowserProfile", + "BrowserVault", + "ClientVault", + "VaultSession", "CekiError", "HumanProfile", "CaptchaResult", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index f8a536e..b95d179 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -105,9 +105,16 @@ def __init__(self, client: "Client", match: Match, *, human="natural") -> None: from ._chat import BrowserChat from ._profile import BrowserProfile + from ._vault import BrowserVault self.chat = BrowserChat(self) self.profile = BrowserProfile(self) + self.vault = BrowserVault(self) + + # Bound vault session id — set when the browser was rented with + # vault= or restored from one (BrowserVault.restore). BrowserVault.save + # PUTs onto this id on overwrite=True. + self._vault_session_id: int | None = None env_profile = os.environ.get("CEKI_HUMAN_PROFILE") env_path = os.environ.get("CEKI_HUMAN_PROFILE_PATH") diff --git a/ceki_sdk/_client.py b/ceki_sdk/_client.py index 325a99b..de45e2c 100644 --- a/ceki_sdk/_client.py +++ b/ceki_sdk/_client.py @@ -73,6 +73,10 @@ def __init__( # shared WebSocket once the last session for a client is gone. self._on_session_ended: Callable[[str], Awaitable[None]] | None = None + # Vault HTTP surface (see ceki_sdk/_vault.py) + from ._vault import ClientVault + self.vault = ClientVault(self) + # P2P WebRTC transport (primary, WS = fallback) self._p2p: WebRTCTransport | None = None self._p2p_init_lock = asyncio.Lock() @@ -198,6 +202,7 @@ async def rent( masking_mode: bool = True, fingerprint: bool | dict | None = True, pacing_profile: str | None = None, + vault: int | dict[str, Any] | None = None, ) -> Browser: if mode not in ("incognito", "main"): raise ValueError(f"mode must be 'incognito' or 'main', got {mode!r}") @@ -235,8 +240,17 @@ async def rent( browser = Browser(client=self, match=match, human=human) self._active_browsers[match.session_id] = browser + with_restored = False + # Vault profile restore — do it first so the rent() fingerprint branch + # below can't clobber a profile-supplied fingerprint. Profile cookies/ + # storage go through session.configure(profile=...) (Vault 3+ extension). + if vault is not None: + await browser.vault.restore(vault) + with_restored = True if not masking_mode: await browser.configure(masking_mode=False) + if with_restored: + return browser if isinstance(fingerprint, dict): await browser.configure(fingerprint=fingerprint) elif fingerprint is False or fingerprint is None: diff --git a/ceki_sdk/_vault.py b/ceki_sdk/_vault.py new file mode 100644 index 0000000..545d3e5 --- /dev/null +++ b/ceki_sdk/_vault.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import base64 +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from ._browser import Browser + from ._client import Client + +log = logging.getLogger(__name__) + +# Fields CDP/the extension accept on Network.setCookies. The raw jar comes back +# with extra diagnostic keys (priority, size, session, sourcePort, +# sourceScheme) that the browser rejects on set — keep only the settable ones. +SERIALIZABLE_COOKIE_FIELDS = ( + "name", "value", "domain", "path", "secure", "httpOnly", + "expires", "sameSite", "session", +) + + +def sanitize_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Strip non-settable CDP fields from cookie objects. + + Network.getCookies returns extra fields (size, sourcePort, ...) that + Network.setCookies rejects; the extension's VaultProfile contract only + carries the settable subset. Returns a shallow copy per cookie. + """ + out: list[dict[str, Any]] = [] + for c in cookies: + if not isinstance(c, dict): + continue + if not c.get("name") or not isinstance(c.get("value"), str): + continue + out.append({k: c[k] for k in SERIALIZABLE_COOKIE_FIELDS if k in c}) + return out + + +def normalize_profile_for_vault(profile: dict[str, Any]) -> dict[str, Any]: + """Convert a ``profile.export()`` blob into the vault session ``data`` envelope. + + ``profile.export()`` returns a flat single-origin snapshot:: + + {schema_version, fingerprint, origin, cookies, + localStorage: {k: v}, sessionStorage: {k: v}} + + The vault API (and the extension's ``session.configure`` profile) expects a + per-origin envelope:: + + {fingerprint, cookies, + localStorage: {: {...}}, sessionStorage: {: {...}}, + urls: [...], collectedAt: ISO} + + localStorage/sessionStorage are best-effort CDP captures of the currently + loaded origin, so the snapshot's ``origin`` is the natural key. If the + incoming storage is already per-origin (values are dicts — e.g. a vault + envelope being re-uploaded), it is passed through untouched. The blob is + already serialisable (no datetimes, no bytes); we don't unparse JSON strings. + """ + envelope: dict[str, Any] = { + "fingerprint": profile.get("fingerprint"), + "cookies": sanitize_cookies(profile.get("cookies", [])), + } + + origin = profile.get("origin") or "https://localhost" + + def _wrap(storage: Any) -> dict[str, Any]: + if not isinstance(storage, dict): + return {} + already_by_origin = all(isinstance(v, dict) for v in storage.values()) + if already_by_origin: + return storage + return {origin: storage} + + envelope["localStorage"] = _wrap(profile.get("localStorage")) + envelope["sessionStorage"] = _wrap(profile.get("sessionStorage")) + + urls = profile.get("urls") + if not isinstance(urls, list): + urls = [origin] if origin and origin != "https://localhost" else [] + envelope["urls"] = [u for u in urls if isinstance(u, str)] + + collected_at = profile.get("collectedAt") + if not collected_at: + collected_at = datetime.now(timezone.utc).isoformat() + envelope["collectedAt"] = collected_at + + return envelope + + +def minimal_vault_profile(data: dict[str, Any]) -> dict[str, Any]: + """Normalize a fetched vault session ``data`` for ``session.configure``. + + Passes through per-origin localStorage/sessionStorage (the extension buffers + them by origin) and sanitizes cookies. ``fingerprint`` is intentionally NOT + copied into the profile — it is applied via the top-level ``fingerprint`` + configure field so the extension's existing fingerprint path stays the single + source of truth. Unknown keys are dropped. + """ + profile: dict[str, Any] = {} + cookies = data.get("cookies") + if isinstance(cookies, list): + profile["cookies"] = sanitize_cookies(cookies) + for key in ("localStorage", "sessionStorage"): + storage = data.get(key) + if isinstance(storage, dict): + profile[key] = storage + return profile + + +class VaultSession: + """Minimal view of a vault session as returned by the API.""" + + __slots__ = ("id", "label", "user_id", "data", "urls", "last_browser", + "created_at", "updated_at") + + def __init__( + self, + id: int, + *, + label: str | None = None, + user_id: int | None = None, + data: dict[str, Any] | None = None, + urls: list[str] | None = None, + last_browser: str | None = None, + created_at: str | None = None, + updated_at: str | None = None, + ) -> None: + self.id = id + self.label = label + self.user_id = user_id + self.data = data or {} + self.urls = urls or [] + self.last_browser = last_browser + self.created_at = created_at + self.updated_at = updated_at + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "VaultSession": + data = payload.get("data") + # The backend may return the encrypted blob as-is (index serializes a + # string) — only treat it as the decrypted profile when it's a dict. + data_out = data if isinstance(data, dict) else {} + # urls are top-level on the paginated index; inside the decrypted + # envelope on show() — make both surfaces readable. + urls = payload.get("urls") + if not urls: + data_urls = data_out.get("urls") + urls = data_urls if isinstance(data_urls, list) else [] + raw_id = payload.get("id") + return cls( + id=int(raw_id) if raw_id is not None else 0, + label=payload.get("label"), + user_id=payload.get("user_id"), + data=data_out, + urls=urls, + last_browser=payload.get("last_browser"), + created_at=payload.get("created_at"), + updated_at=payload.get("updated_at"), + ) + + +class ClientVault: + """HTTP client for ``/api/vault/sessions`` (Vault 1-5 backend). + + Lives on :attr:`Client.vault`. All methods are plain ``httpx`` calls — no + relay / websocket involvement — so they work before a session is rented. + + The vault routes are guarded by Sanctum (``auth:sanctum``) and resolve the + token to a *user*; agent ``ag_`` keys are accepted by the same guard on the + dev backend through ``AuthenticateSanctumOrAgent`` only if the backend later + wires them in. For now, use a user Sanctum token as ``api_key`` for vault + operations (the SDK's BS ``Authorization: Bearer`` header stays unchanged). + """ + + def __init__(self, client: "Client") -> None: + self._client = client + + def _headers(self) -> dict[str, str]: + headers = {"Authorization": f"Bearer {self._client.api_key}"} + if self._client._basic_auth: + raw = f"{self._client._basic_auth[0]}:{self._client._basic_auth[1]}" + creds = base64.b64encode(raw.encode()).decode() + headers["X-Basic-Auth"] = f"Basic {creds}" + return headers + + async def list(self, **params: Any) -> list[VaultSession]: + """List vault sessions (paginated by the backend, default 20/page).""" + url = f"{self._client.api_url}/api/vault/sessions" + async with httpx.AsyncClient() as http: + resp = await http.get(url, headers=self._headers(), params=params or {"per_page": 20}) + resp.raise_for_status() + body = resp.json() + items = body.get("data", body) if isinstance(body, dict) else body + return [VaultSession.from_payload(x) for x in items if isinstance(x, dict)] + + async def get(self, vault_session_id: int) -> VaultSession: + """Fetch a vault session with its DECRYPTED ``data`` profile (show).""" + url = f"{self._client.api_url}/api/vault/sessions/{vault_session_id}" + async with httpx.AsyncClient() as http: + resp = await http.get(url, headers=self._headers()) + resp.raise_for_status() + return VaultSession.from_payload(resp.json()) + + async def create(self, data: dict[str, Any], *, label: str | None = None) -> int: + """Create a vault session; returns the new session id.""" + url = f"{self._client.api_url}/api/vault/sessions" + payload: dict[str, Any] = {"data": data} + if label is not None: + payload["label"] = label + async with httpx.AsyncClient() as http: + resp = await http.post(url, headers=self._headers(), json=payload) + resp.raise_for_status() + return int(resp.json().get("id")) + + async def update( + self, vault_session_id: int, data: dict[str, Any], *, label: str | None = None + ) -> VaultSession: + """Overwrite an existing vault session's data (PUT, server encrypts).""" + url = f"{self._client.api_url}/api/vault/sessions/{vault_session_id}" + payload: dict[str, Any] = {"data": data} + if label is not None: + payload["label"] = label + async with httpx.AsyncClient() as http: + resp = await http.put(url, headers=self._headers(), json=payload) + resp.raise_for_status() + return VaultSession.from_payload(resp.json()) + + async def delete(self, vault_session_id: int) -> None: + """Delete a vault session (owner only).""" + url = f"{self._client.api_url}/api/vault/sessions/{vault_session_id}" + async with httpx.AsyncClient() as http: + resp = await http.delete(url, headers=self._headers()) + resp.raise_for_status() + + +class BrowserVault: + """Vault sugar on a live :class:`Browser` (snapshot save / profile restore). + + Available as :attr:`Browser.vault`. Saving snapshots the current browser + state through ``browser.profile.export()`` and pushes it to the vault. + Restoring pulls a vault session and replays it into the current browser via + ``session.configure(profile=...)`` (cookies immediately, storage buffered by + the extension until first navigation to each origin) plus a fingerprint + configure when the profile carries one. + """ + + def __init__(self, browser: "Browser") -> None: + self._browser = browser + self._client_vault = browser._client.vault + + async def save( + self, + *, + label: str | None = None, + include_session_storage: bool = True, + domains: list[str] | None = None, + overwrite: bool = False, + ) -> int: + """Snapshot the current browser into a vault session. + + Returns the vault session id. When the browser was rented with + ``vault=`` (a bound session), the snapshot overwrites that session + (PUT). Otherwise a new session is created (POST). ``overwrite=True`` + forces a PUT against the bound id (no-op if no bound id). + """ + profile = await self._browser.profile.export( + include_session_storage=include_session_storage, + domains=domains, + ) + envelope = normalize_profile_for_vault(profile) + bound_id = getattr(self._browser, "_vault_session_id", None) + if overwrite or (bound_id is not None and not label): + target = bound_id + if target is None: + raise ValueError("no bound vault session to overwrite; rent with vault=") + await self._client_vault.update(target, envelope, label=label) + return int(target) + return await self._client_vault.create(envelope, label=label) + + async def load(self, vault_session_id: int) -> dict[str, Any]: + """Fetch a vault session's decrypted profile envelope (``data``).""" + session = await self._client_vault.get(vault_session_id) + if not session.data: + raise ValueError(f"vault session {vault_session_id} has no profile data") + return session.data + + async def restore(self, vault_session_id: int | dict[str, Any]) -> None: + """Replay a vault profile into the current browser. + + ``vault_session_id`` may be an int (fetched from the API) or a raw + profile envelope dict. Cookies are applied immediately (domain-scoped); + localStorage/sessionStorage are buffered by the extension and flushed on + first navigation to each origin; fingerprint (if present in the profile) + is applied through ``session.configure``. + """ + if isinstance(vault_session_id, dict): + data = vault_session_id + else: + data = await self.load(int(vault_session_id)) + self._browser._vault_session_id = int(vault_session_id) + + profile = minimal_vault_profile(data) + fingerprint = data.get("fingerprint") + if isinstance(fingerprint, dict) and not fingerprint: + fingerprint = None + + payload: dict[str, Any] = {"profile": profile} + if isinstance(fingerprint, dict) and fingerprint: + payload["fingerprint"] = fingerprint + await self._browser.configure(**payload) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 24d57db..60de476 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -274,11 +274,15 @@ async def _cmd_rent(args: argparse.Namespace) -> None: # Try daemon IPC fp_from = str(Path(args.fingerprint_from).resolve()) if args.fingerprint_from else None + vault_arg: int | None = None + if getattr(args, "vault", None): + vault_arg = int(args.vault) try: ok, result = await _daemon_request("/rent", { "schedule": args.schedule, "mode": args.mode, "fingerprint_from": fp_from, + "vault": vault_arg, }) if ok: sid = result["session_id"] @@ -303,7 +307,9 @@ async def _cmd_rent(args: argparse.Namespace) -> None: fp_data = profile.get("fingerprint") or True client = await connect(api_key, _connect_options()) try: - browser = await client.rent(args.schedule, mode=args.mode, fingerprint=fp_data) + browser = await client.rent( + args.schedule, mode=args.mode, fingerprint=fp_data, vault=vault_arg, + ) save_session(browser.session_id, { "session_id": browser.session_id, "chat_topic_id": browser.chat_topic_id, @@ -314,6 +320,7 @@ async def _cmd_rent(args: argparse.Namespace) -> None: "session_id": browser.session_id, "chat_topic_id": browser.chat_topic_id, "schedule_id": browser.schedule_id, + "vault_session_id": getattr(browser, "_vault_session_id", None), }) finally: if client._ws: @@ -689,6 +696,206 @@ async def _cmd_sessions(args: argparse.Namespace) -> None: await client.disconnect() +# ── Vault subcommands (task 11622 — Vault 7) ──────────────────────────────── + + +def _vault_http_client() -> Any: + """A ``Client`` wired for plain HTTP vault calls — no relay / WebSocket. + + The vault routes are plain ``httpx`` calls keyed off ``api_url`` + (see ClientVault), so unlike every other CLI command this intentionally + does NOT call ``connect()`` and never opens the relay WebSocket. The API + key and any ``CEKI_API_URL`` / basic-auth overrides are read exactly like + ``connect()`` would, keeping dev/staging usage identical. + """ + from ._client import Client + + api_key = _get_api_key() + opts = _connect_options() + return Client( + api_key=api_key, + relay_url=opts.relay_url if opts.relay_url else "wss://browser.ceki.me/ws/agent", + api_url=opts.api_url if opts.api_url else "https://api.ceki.me", + chat_url=opts.chat_url if opts.chat_url else "https://chat.ceki.me/api/chat", + reconnect=False, + basic_auth=opts.basic_auth, + ) + + +def _vault_session_summary(s) -> dict[str, Any]: + """Compact summary of a VaultSession for list/get output.""" + data = s.data if isinstance(s.data, dict) else {} + n_cookies = len(data.get("cookies", [])) if isinstance(data.get("cookies"), list) else 0 + storage = data.get("localStorage", {}) + n_origins = len(storage) if isinstance(storage, dict) else 0 + urls = s.urls or (data.get("urls", []) if isinstance(data.get("urls"), list) else []) + return { + "id": s.id, + "label": s.label, + "user_id": s.user_id, + "urls": urls, + "cookie_count": n_cookies, + "storage_origins": n_origins, + "created_at": s.created_at, + "updated_at": s.updated_at, + "last_browser": s.last_browser, + } + + +def _print_vault_list(sessions) -> None: + if not sessions: + print("No vault sessions.") + return + header = f"{'ID':<6}{'LABEL':<28}{'URLS':<40}{'UPDATED'}" + print(header) + for s in sessions: + urls = ", ".join(s.urls[:2]) if s.urls else "—" + if len(s.urls) > 2: + urls += "…" + updated = s.updated_at or "—" + print(f"{s.id:<6}{(s.label or '—')[:27]:<28}{urls[:39]:<40}{updated}") + + +def _load_vault_profile(path: str) -> dict[str, Any]: + """Load a vault profile JSON file (create/update source).""" + srcfile = Path(path) + if not srcfile.is_file(): + raise FileNotFoundError(f"profile file not found: {path}") + with open(srcfile) as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"profile file must contain a JSON object, got {type(data).__name__}") + return data + + +def _validate_vault_payload(data: dict[str, Any]) -> None: + """Best-effort shape check; the server encrypts and re-validates anyway.""" + if "cookies" in data and not isinstance(data["cookies"], list): + raise ValueError("vault profile 'cookies' must be an array") + for key in ("localStorage", "sessionStorage"): + if key in data and not isinstance(data[key], dict): + raise ValueError(f"vault profile '{key}' must be an object") + + +async def _cmd_vault(args: argparse.Namespace) -> None: + """``ceki vault …`` — list/get/save/apply/delete vault sessions. + + Vault routes are Sanctum user-scoped, so the token must be a user + Sanctum token (see ClientVault docstring); agent ``ag_`` keys are not + accepted on prod yet. All subcommands run over plain HTTP — no relay + session needed except ``apply --session`` (resume) / ``apply --schedule`` + (rent). + """ + action = args.vault_action + client = _vault_http_client() + + if action == "list": + sessions = await client.vault.list(per_page=getattr(args, "per_page", 20)) + if getattr(args, "json", False): + _out([_vault_session_summary(s) for s in sessions]) + else: + _print_vault_list(sessions) + return + + if action == "get": + session = await client.vault.get(int(args.id)) + if getattr(args, "json", False): + _out(session.data or {}) + return + summary = _vault_session_summary(session) + print(f"Vault session {session.id}:") + print(f" label: {session.label or '—'}") + print(f" user_id: {session.user_id or '—'}") + print(f" updated: {session.updated_at or '—'}") + print(f" cookies: {summary['cookie_count']}") + print(f" storage: {summary['storage_origins']} origin(s)") + print(f" urls: {', '.join(summary['urls']) if summary['urls'] else '—'}") + print(" (use --json for the full decrypted profile or --output to dump it)") + if getattr(args, "output", None): + with open(args.output, "w") as f: + json.dump(session.data or {}, f, indent=2, ensure_ascii=False) + print(f"Saved decrypted profile to {args.output}") + return + + if action == "save": + if args.path: + profile = _load_vault_profile(args.path) + _validate_vault_payload(profile) + try: + from ._vault import normalize_profile_for_vault + envelope = normalize_profile_for_vault(profile) + except Exception: + # Not a profile.export() snapshot — pass the file through as the + # raw vault envelope the API can encrypt as-is. + envelope = profile + label = args.label or Path(args.path).name + elif args.session: + # Snapshot a live rental session, then normalize the exported + # profile exactly like BrowserVault.save would. + from ._vault import normalize_profile_for_vault + + api_key = _get_api_key() + client2, browser = await _resume_browser(api_key, args.session) + try: + profile = await browser.profile.export( + include_session_storage=not args.no_session_storage, + ) + finally: + if client2 and client2._ws: + await client2.disconnect() + envelope = normalize_profile_for_vault(profile) + label = args.label or f"session {args.session}" + else: + raise CekiError("vault save needs or --session ") + + if args.id is not None: + session = await client.vault.update(int(args.id), envelope, label=label) + _out({"ok": True, "id": session.id, "action": "updated", "label": label}) + else: + vid = await client.vault.create(envelope, label=label) + _out({"ok": True, "id": vid, "action": "created", "label": label}) + return + + if action == "apply": + vid = int(args.id) + if args.session: + # Apply into an existing rental: resume + restore (session.configure). + api_key = _get_api_key() + client2, browser = await _resume_browser(api_key, args.session) + try: + await browser.vault.restore(vid) + _out({"ok": True, "vault_session_id": vid, "session_id": browser.session_id}) + finally: + if client2 and client2._ws: + await client2.disconnect() + return + if args.schedule is None: + raise CekiError("vault apply needs --session or --schedule ") + # Fresh rental with the vault profile restored on rent. + api_key = _get_api_key() + _client = await connect(api_key, _connect_options()) + try: + browser = await _client.rent(schedule_id=args.schedule, vault=vid) + _out({ + "ok": True, + "vault_session_id": vid, + "session_id": browser.session_id, + "schedule_id": browser.schedule_id, + }) + finally: + if _client._ws: + await _client.disconnect() + return + + if action == "delete": + await client.vault.delete(int(args.id)) + _out({"ok": True, "id": int(args.id), "deleted": True}) + return + + _err(f"unknown vault action: {action}") + sys.exit(1) + + async def _cmd_my_browsers(args: argparse.Namespace) -> None: api_key = _get_api_key() client = await connect(api_key, _connect_options()) @@ -1230,6 +1437,8 @@ def build_parser() -> argparse.ArgumentParser: default="incognito", help="Profile mode (default: incognito)", ) p_rent.add_argument("--fingerprint-from", help="Path to profile JSON with fingerprint data") + p_rent.add_argument("--vault", type=int, metavar="SESSION_ID", + help="Vault session id to restore (cookies+storage+fingerprint)") p_snap = sub.add_parser("snapshot", help="Take screenshot + get new chat messages") p_snap.add_argument("session_id", help="Session ID") @@ -1311,6 +1520,50 @@ def build_parser() -> argparse.ArgumentParser: p_sessions.add_argument("--limit", type=int, default=50, help="Max results") p_sessions.add_argument("--json", action="store_true", help="Raw JSON output") + # ── vault subcommand ──────────────────────────────────────────────── + p_vault = sub.add_parser( + "vault", + help="Manage user vault sessions (cookies+localStorage profiles)", + ) + vsub = p_vault.add_subparsers(dest="vault_action", required=True) + + p_vl = vsub.add_parser("list", help="List vault sessions (user-scoped, Sanctum token)") + p_vl.add_argument("--per-page", type=int, default=20, dest="per_page", + help="Items per page (backend default 20)") + p_vl.add_argument("--json", action="store_true", help="Raw JSON output") + + p_vg = vsub.add_parser("get", help="Show a vault session (decrypted profile)") + p_vg.add_argument("id", type=int, help="Vault session id") + p_vg.add_argument("--json", action="store_true", + help="Print the full decrypted profile as JSON") + p_vg.add_argument("-o", "--output", help="Dump the decrypted profile to a JSON file") + + p_vs = vsub.add_parser( + "save", + help="Create (or update with --id) a vault session from a profile file " + "or a live rental session snapshot", + ) + p_vs.add_argument("path", nargs="?", help="Path to profile JSON " + "(profile.export() snapshot or vault envelope)") + p_vs.add_argument("--session", help="Rental session id to snapshot (via profile.export)") + p_vs.add_argument("--id", type=int, help="Existing vault session id to overwrite (PUT)") + p_vs.add_argument("--no-session-storage", action="store_true", + help="With --session: exclude sessionStorage from the snapshot") + p_vs.add_argument("--label", help="Session label (default: basename of the file / snapshot)") + + p_va = vsub.add_parser( + "apply", + help="Apply a vault session: fresh rent (--schedule) or into a live session (--session)", + ) + p_va.add_argument("id", type=int, help="Vault session id") + p_va.add_argument("--schedule", type=int, + help="Rent a new browser with this schedule and restore the vault") + p_va.add_argument("--session", + help="Resume an existing rental session id and restore the vault") + + p_vd = vsub.add_parser("delete", help="Delete a vault session (owner only)") + p_vd.add_argument("id", type=int, help="Vault session id") + sub.add_parser("my-browsers", help="List browsers with pre-arranged rent contracts") p_search = sub.add_parser("search", help="Search available browsers") @@ -1698,6 +1951,7 @@ def main() -> None: "stop": _cmd_stop, "profile": _cmd_profile, "sessions": _cmd_sessions, + "vault": _cmd_vault, "my-browsers": _cmd_my_browsers, "search": _cmd_search, "wait": _cmd_wait, diff --git a/ceki_sdk/daemon.py b/ceki_sdk/daemon.py index 51a1797..509fc84 100644 --- a/ceki_sdk/daemon.py +++ b/ceki_sdk/daemon.py @@ -152,6 +152,7 @@ async def _handle_rent(self, params: dict) -> dict: if not schedule: raise ValueError("schedule (int) required") mode = params.get("mode", "incognito") + vault_arg = params.get("vault") fp_data: bool | dict = True fp_from = params.get("fingerprint_from") if fp_from: @@ -176,7 +177,7 @@ async def _shared_client(): client = await _shared_client() try: - browser = await client.rent(schedule, mode=mode, fingerprint=fp_data) + browser = await client.rent(schedule, mode=mode, fingerprint=fp_data, vault=vault_arg) except (TimeoutError, ConnectionLost) as exc: # The shared WS is half-dead: the relay stopped routing rent/match # without a close frame, so the TCP socket stays ESTABLISHED, @@ -191,7 +192,9 @@ async def _shared_client(): await daemon._drop_client(api_key, client) client = await _shared_client() try: - browser = await client.rent(schedule, mode=mode, fingerprint=fp_data) + browser = await client.rent( + schedule, mode=mode, fingerprint=fp_data, vault=vault_arg, + ) except Exception: await daemon._drop_client(api_key, client) raise @@ -209,6 +212,7 @@ async def _shared_client(): "session_id": browser.session_id, "chat_topic_id": browser.chat_topic_id, "schedule_id": browser.schedule_id, + "vault_session_id": getattr(browser, "_vault_session_id", None), } async def _handle_navigate(self, params: dict) -> None: diff --git a/examples/vault_roundtrip.py b/examples/vault_roundtrip.py new file mode 100644 index 0000000..1a96737 --- /dev/null +++ b/examples/vault_roundtrip.py @@ -0,0 +1,101 @@ +"""Browser Vault roundtrip — save a session snapshot to the vault and restore it. + +Requires: a user Sanctum token (the vault endpoints resolve to a user). +Point CEKI_API_URL at your API environment (defaults to https://api.ceki.me). + +Two modes: + 1. ``export-envelope`` — read a locally exported profile.json and push it to + the vault (no live browser needed). + 2. ``save`` — rent a browser, export its state, POST/PUT to the vault. + 3. ``rent-with-vault`` — rent a browser and restore a vault session by id. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys + +from ceki_sdk import ConnectOptions, connect + + +def _raw_arg() -> str: + key = os.environ.get("CEKI_API_KEY") + if not key: + print("CEKI_API_KEY not set", file=sys.stderr) + sys.exit(2) + return key + + +async def cmd_export_envelope(file: str, label: str) -> None: + with open(file) as f: + profile = json.load(f) + client = await connect(_raw_arg(), ConnectOptions(reconnect=False)) + try: + # If the file is already a vault envelope (has a "data" key), pass data + # as-is; otherwise normalize a flat profile.export() blob. + if isinstance(profile, dict) and "data" in profile: + envelope = profile["data"] + else: + from ceki_sdk._vault import normalize_profile_for_vault + + envelope = normalize_profile_for_vault(profile) + vid = await client.vault.create(envelope, label=label) + print(f"created vault session {vid}") + finally: + await client.disconnect() + + +async def cmd_save(schedule_id: int) -> None: + client = await connect(_raw_arg(), ConnectOptions(reconnect=False)) + try: + browser = await client.rent(schedule_id) + await browser.send({"method": "Page.navigate", "params": {"url": "https://vc.ru"}}) + await asyncio.sleep(3) + vid = await browser.vault.save(label="vc.ru snapshot") + print(f"saved vault session {vid}") + await browser.close() + finally: + await client.disconnect() + + +async def cmd_rent_vault(schedule_id: int, vault_id: int) -> None: + client = await connect(_raw_arg(), ConnectOptions(reconnect=False)) + try: + browser = await client.rent(schedule_id, vault=vault_id) + print(f"rented {browser.session_id} with vault {vault_id}") + # cookies are applied; navigate to trigger per-origin storage flush + await browser.send({"method": "Page.navigate", "params": {"url": "https://vc.ru"}}) + await asyncio.sleep(3) + await browser.close() + finally: + await client.disconnect() + + +async def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + pe = sub.add_parser("export-envelope") + pe.add_argument("file", help="path to an exported profile.json") + pe.add_argument("--label", default="Snapshot") + + ps = sub.add_parser("save") + ps.add_argument("--schedule", type=int, required=True) + + pr = sub.add_parser("rent-with-vault") + pr.add_argument("--schedule", type=int, required=True) + pr.add_argument("--vault", type=int, required=True) + + args = p.parse_args() + if args.cmd == "export-envelope": + await cmd_export_envelope(args.file, args.label) + elif args.cmd == "save": + await cmd_save(args.schedule) + elif args.cmd == "rent-with-vault": + await cmd_rent_vault(args.schedule, args.vault) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c97be5a..32daa97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.37.0" +version = "2.37.2" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_cli_vault.py b/tests/test_cli_vault.py new file mode 100644 index 0000000..cb2c7c2 --- /dev/null +++ b/tests/test_cli_vault.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ceki_sdk._vault import VaultSession +from ceki_sdk.cli import _cmd_vault, build_parser + +# A decrypted vault envelope as GET /api/vault/sessions/{id} returns it. +SAMPLE_DATA = { + "fingerprint": {"userAgent": "Mozilla/5.0"}, + "cookies": [ + { + "name": "auth-refresh-remember", + "value": "ed2679cc", + "domain": ".vc.ru", + "path": "/", + "secure": True, + "httpOnly": True, + "expires": 1796120019.91, + "sameSite": "Lax", + "session": False, + } + ], + "localStorage": {"https://vc.ru": {"user": '{"id":1}'}}, + "sessionStorage": {}, + "urls": ["https://vc.ru/"], + "collectedAt": "2026-10-02T10:13:43.756Z", +} + + +def _vault_session(**overrides): + payload = { + "id": 8, + "label": "vc.ru profile", + "user_id": 1, + "created_at": "2026-10-01T00:00:00Z", + "updated_at": "2026-10-02T00:00:00Z", + } + payload.update(overrides) + return VaultSession.from_payload(payload) + + +# ────────────────────────────────────────────────────────────────────────── +# Parser tests +# ────────────────────────────────────────────────────────────────────────── + + +def test_parser_vault_list(): + args = build_parser().parse_args(["vault", "list", "--json"]) + assert args.command == "vault" + assert args.vault_action == "list" + assert args.json is True + + +def test_parser_vault_get(): + args = build_parser().parse_args(["vault", "get", "42", "--json"]) + assert args.vault_action == "get" + assert args.id == 42 + assert args.json is True + + +def test_parser_vault_get_output(): + args = build_parser().parse_args(["vault", "get", "42", "-o", "/tmp/p.json"]) + assert args.output == "/tmp/p.json" + + +def test_parser_vault_save_path(): + args = build_parser().parse_args(["vault", "save", "/tmp/p.json"]) + assert args.vault_action == "save" + assert args.path == "/tmp/p.json" + assert args.session is None + assert args.id is None + + +def test_parser_vault_save_session(): + args = build_parser().parse_args(["vault", "save", "--session", "sess-1", "--label", "L"]) + assert args.vault_action == "save" + assert args.session == "sess-1" + assert args.path is None + assert args.label == "L" + assert args.no_session_storage is False + + +def test_parser_vault_save_update(): + args = build_parser().parse_args(["vault", "save", "/tmp/p.json", "--id", "7"]) + assert args.id == 7 + + +def test_parser_vault_apply_schedule(): + args = build_parser().parse_args(["vault", "apply", "8", "--schedule", "240"]) + assert args.vault_action == "apply" + assert args.id == 8 + assert args.schedule == 240 + assert args.session is None + + +def test_parser_vault_apply_session(): + args = build_parser().parse_args(["vault", "apply", "8", "--session", "s1"]) + assert args.session == "s1" + assert args.schedule is None + + +def test_parser_vault_delete(): + args = build_parser().parse_args(["vault", "delete", "8"]) + assert args.vault_action == "delete" + assert args.id == 8 + + +# ────────────────────────────────────────────────────────────────────────── +# Handler tests (mocked ClientVault) +# ────────────────────────────────────────────────────────────────────────── + + +def _mock_http_client(vault_mock: MagicMock): + client = MagicMock() + client.vault = vault_mock + return client + + +async def _run(args: list[str], vault_mock: MagicMock): + parsed = build_parser().parse_args(["vault", *args]) + with patch( + "ceki_sdk.cli._vault_http_client", + return_value=_mock_http_client(vault_mock), + ), patch.dict("os.environ", {"CEKI_API_KEY": "testkey"}, clear=False): + await _cmd_vault(parsed) + + +@pytest.mark.asyncio +async def test_vault_list_calls_api_and_outputs_summary(capsys): + vault = MagicMock() + vault.list = AsyncMock(return_value=[ + _vault_session(), + _vault_session(id=9, label="Other", urls=["https://other.ru/"]), + ]) + await _run(["list"], vault) + vault.list.assert_awaited_once_with(per_page=20) + out = capsys.readouterr().out + assert "8" in out + assert "vc.ru profile" in out + assert "URLS" in out + + +@pytest.mark.asyncio +async def test_vault_list_json(capsys): + vault = MagicMock() + vault.list = AsyncMock(return_value=[ + _vault_session(data=SAMPLE_DATA), + ]) + await _run(["list", "--json"], vault) + parsed = json.loads(capsys.readouterr().out) + assert parsed[0]["id"] == 8 + assert parsed[0]["cookie_count"] == 1 + assert parsed[0]["storage_origins"] == 1 + + +@pytest.mark.asyncio +async def test_vault_get_json_prints_decrypted_data(capsys): + vault = MagicMock() + vault.get = AsyncMock(return_value=_vault_session(data=SAMPLE_DATA)) + await _run(["get", "8", "--json"], vault) + vault.get.assert_awaited_once_with(8) + parsed = json.loads(capsys.readouterr().out) + assert parsed["cookies"][0]["name"] == "auth-refresh-remember" + assert parsed["localStorage"]["https://vc.ru"]["user"] + + +@pytest.mark.asyncio +async def test_vault_get_human_summary(capsys): + vault = MagicMock() + vault.get = AsyncMock(return_value=_vault_session(data=SAMPLE_DATA)) + await _run(["get", "8"], vault) + out = capsys.readouterr().out + assert "Vault session 8" in out + assert "cookies: 1" in out + assert "https://vc.ru/" in out + + +@pytest.mark.asyncio +async def test_vault_get_output_dumps_profile(tmp_path: Path, capsys): + vault = MagicMock() + vault.get = AsyncMock(return_value=_vault_session(data=SAMPLE_DATA)) + out_path = tmp_path / "profile.json" + await _run(["get", "8", "-o", str(out_path)], vault) + dumped = json.loads(out_path.read_text()) + assert dumped["cookies"][0]["name"] == "auth-refresh-remember" + assert "Saved decrypted profile" in capsys.readouterr().out + + +@pytest.mark.asyncio +async def test_vault_save_creates_new_session(tmp_path: Path, capsys): + vault = MagicMock() + vault.create = AsyncMock(return_value=77) + profile = tmp_path / "p.json" + profile.write_text(json.dumps(SAMPLE_DATA)) + await _run(["save", str(profile), "--label", "My"], vault) + created_call = vault.create.await_args + assert created_call.kwargs["label"] == "My" + # profile.export() snapshot shape gets normalized into a vault envelope + assert "cookies" in created_call.args[0] + out = json.loads(capsys.readouterr().out) + assert out == {"ok": True, "id": 77, "action": "created", "label": "My"} + + +@pytest.mark.asyncio +async def test_vault_save_updates_existing_session(tmp_path: Path): + vault = MagicMock() + vault.update = AsyncMock(return_value=_vault_session()) + vault.create = AsyncMock(return_value=0) # must never be awaited + profile = tmp_path / "p.json" + profile.write_text(json.dumps(SAMPLE_DATA)) + await _run(["save", str(profile), "--id", "8"], vault) + updated = vault.update.await_args + assert updated.args[0] == 8 + assert updated.args[1]["cookies"] + assert vault.create.await_args is None + + +@pytest.mark.asyncio +async def test_vault_save_missing_file_raises(): + vault = MagicMock() + with pytest.raises(FileNotFoundError): + await _run(["save", "/does/not/exist.json"], vault) + + +@pytest.mark.asyncio +async def test_vault_save_requires_source(): + vault = MagicMock() + from ceki_sdk._exceptions import CekiError + with pytest.raises(CekiError): + await _run(["save"], vault) + + +@pytest.mark.asyncio +async def test_vault_save_from_session_snapshots(capsys): + vault = MagicMock() + vault.create = AsyncMock(return_value=99) + + profile = { + "schema_version": 2, + "fingerprint": {"userAgent": "x"}, + "origin": "https://vc.ru", + "cookies": [{"name": "a", "value": "1", "domain": ".vc.ru"}], + "localStorage": {"user": '{"id":1}'}, + "sessionStorage": {}, + } + browser = MagicMock() + browser.profile.export = AsyncMock(return_value=profile) + resume_browser = AsyncMock(return_value=(None, browser)) + + parsed = build_parser().parse_args( + ["vault", "save", "--session", "sess-1", "--label", "snap"] + ) + with patch( + "ceki_sdk.cli._vault_http_client", + return_value=_mock_http_client(vault), + ), patch("ceki_sdk.cli._resume_browser", resume_browser), patch.dict( + "os.environ", {"CEKI_API_KEY": "testkey"}, clear=False + ): + await _cmd_vault(parsed) + + browser.profile.export.assert_awaited_once_with(include_session_storage=True) + created = vault.create.await_args + assert created.kwargs["label"] == "snap" + assert created.args[0]["localStorage"]["https://vc.ru"]["user"] + out = json.loads(capsys.readouterr().out) + assert out["id"] == 99 + + +@pytest.mark.asyncio +async def test_vault_apply_to_existing_session(capsys): + vault = MagicMock() + browser = MagicMock() + browser.session_id = "sess-1" + browser.vault.restore = AsyncMock() + resume_browser = AsyncMock(return_value=(None, browser)) + + parsed = build_parser().parse_args(["vault", "apply", "8", "--session", "sess-1"]) + with patch( + "ceki_sdk.cli._vault_http_client", + return_value=_mock_http_client(vault), + ), patch("ceki_sdk.cli._resume_browser", resume_browser), patch.dict( + "os.environ", {"CEKI_API_KEY": "testkey"}, clear=False + ): + await _cmd_vault(parsed) + + browser.vault.restore.assert_awaited_once_with(8) + out = json.loads(capsys.readouterr().out) + assert out["session_id"] == "sess-1" + + +@pytest.mark.asyncio +async def test_vault_apply_requires_target(): + vault = MagicMock() + from ceki_sdk._exceptions import CekiError + with pytest.raises(CekiError): + await _run(["apply", "8"], vault) + + +@pytest.mark.asyncio +async def test_vault_delete(capsys): + vault = MagicMock() + vault.delete = AsyncMock() + await _run(["delete", "8"], vault) + vault.delete.assert_awaited_once_with(8) + out = json.loads(capsys.readouterr().out) + assert out == {"ok": True, "id": 8, "deleted": True} \ No newline at end of file diff --git a/tests/test_vault.py b/tests/test_vault.py new file mode 100644 index 0000000..442909b --- /dev/null +++ b/tests/test_vault.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from ceki_sdk import ConnectOptions, connect +from ceki_sdk._client import Client +from ceki_sdk._profile import BrowserProfile +from ceki_sdk._vault import ( + VaultSession, + minimal_vault_profile, + normalize_profile_for_vault, + sanitize_cookies, +) + +from .conftest import MockRelayServer + +# A decrypted vault session envelope exactly as /api/vault/sessions/{id} returns it. +SAMPLE_VAULT_DATA = { + "fingerprint": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/153.0.0.0", + "canvasNoise": 0.00041478621121495963, + }, + "cookies": [ + { + "name": "auth-refresh-remember", + "value": "ed2679ccfa8e6b3ab8dc3ec0215363ab", + "domain": ".vc.ru", + "path": "/", + "secure": True, + "httpOnly": True, + "expires": 1796120019.910119, + "sameSite": "Lax", + "priority": "Medium", + "session": False, + "size": 85, + "sourcePort": 443, + "sourceScheme": "Secure", + } + ], + "localStorage": { + "https://vc.ru": { + "user": '{"id":5537554,"name":"Kom"}', + "auth-refresh-token": '{"token":"ed2679cc","expTimestamp":1796120019}', + } + }, + "sessionStorage": {"https://vc.ru": {"__ym_tab_guid": "62152f98"}}, + "urls": ["https://vc.ru/education_on_vc_ru/3163579"], + "collectedAt": "2026-10-02T10:13:43.756Z", +} + + +def _make_response(status: int = 200, json_data: dict | list | None = None) -> httpx.Response: + req = httpx.Request("GET", "http://test") + return httpx.Response(status, json=json_data, request=req) + + +def _make_client(relay_url: str = "wss://relay.ceki.me/ws/agent") -> Client: + return Client( + api_key="testkey", + relay_url=relay_url, + api_url="https://api.ceki.me", + chat_url="https://chat.ceki.me/api/chat", + reconnect=False, + ) + + +# ── sanitize_cookies ───────────────────────────────────────────────────────── + +def test_sanitize_cookies_drops_cdp_only_fields() -> None: + raw = [ + { + "name": "a", + "value": "1", + "domain": ".vc.ru", + "path": "/", + "secure": True, + "httpOnly": True, + "expires": 1796120019.91, + "sameSite": "Lax", + "priority": "Medium", + "size": 85, + "sourcePort": 443, + "sourceScheme": "Secure", + "session": False, + } + ] + out = sanitize_cookies(raw) + assert out == [ + { + "name": "a", + "value": "1", + "domain": ".vc.ru", + "path": "/", + "secure": True, + "httpOnly": True, + "expires": 1796120019.91, + "sameSite": "Lax", + "session": False, + } + ] + + +def test_sanitize_cookies_skips_invalid_entries() -> None: + raw = [ + {"name": "ok", "value": "1", "domain": ".x.com"}, + {"name": "no-value", "domain": ".x.com"}, # missing value + {"name": "int-value", "value": 2, "domain": ".x.com"}, # non-str value + "not-a-dict", + ] + out = sanitize_cookies(raw) + assert len(out) == 1 + assert out[0]["name"] == "ok" + + +# ── profile envelope conversion ────────────────────────────────────────────── + +def test_normalize_profile_to_vault_envelope() -> None: + profile = { + "schema_version": 2, + "fingerprint": {"userAgent": "x"}, + "origin": "https://vc.ru", + "cookies": [ + { + "name": "a", + "value": "1", + "domain": ".vc.ru", + "session": False, + "priority": "Medium", + } + ], + "localStorage": {"user": '{"id":5537554}'}, + "sessionStorage": {"__ym_tab_guid": "x"}, + } + env = normalize_profile_for_vault(profile) + assert env["fingerprint"] == {"userAgent": "x"} + # extra CDP-only cookie fields dropped + assert env["cookies"][0] == {"name": "a", "value": "1", "domain": ".vc.ru", "session": False} + # flat storage is wrapped under the exported origin + assert env["localStorage"] == {"https://vc.ru": {"user": '{"id":5537554}'}} + assert env["sessionStorage"] == {"https://vc.ru": {"__ym_tab_guid": "x"}} + assert env["urls"] == ["https://vc.ru"] + assert env["collectedAt"] + + +def test_normalize_profile_missing_origin() -> None: + env = normalize_profile_for_vault( + {"fingerprint": None, "cookies": [], "localStorage": {}, "sessionStorage": {}} + ) + assert env["localStorage"] == {} + assert env["sessionStorage"] == {} + assert env["urls"] == [] + + +def test_normalize_profile_passes_through_per_origin_storage() -> None: + # A re-uploaded vault envelope already has per-origin storage — the values + # are dicts, so don't re-wrap under the flat origin key. + env = normalize_profile_for_vault( + { + "origin": "https://vc.ru", + "cookies": [], + "fingerprint": None, + "localStorage": {"https://vc.ru": {"user": "x"}}, + "sessionStorage": {"https://vc.ru": {"t": "y"}}, + } + ) + assert env["localStorage"]["https://vc.ru"] == {"user": "x"} + assert env["sessionStorage"]["https://vc.ru"] == {"t": "y"} + + +def test_minimal_vault_profile_passthrough_and_drop() -> None: + prof = minimal_vault_profile(SAMPLE_VAULT_DATA) + assert prof["cookies"] == [ + { + "name": "auth-refresh-remember", + "value": "ed2679ccfa8e6b3ab8dc3ec0215363ab", + "domain": ".vc.ru", + "path": "/", + "secure": True, + "httpOnly": True, + "expires": 1796120019.910119, + "sameSite": "Lax", + "session": False, + } + ] + # per-origin storage kept intact + assert prof["localStorage"]["https://vc.ru"]["user"] == '{"id":5537554,"name":"Kom"}' + assert prof["sessionStorage"]["https://vc.ru"]["__ym_tab_guid"] == "62152f98" + # fingerprint is NOT part of the extension profile — it is applied via the + # top-level session.configure fingerprint field (single source of truth) + assert "fingerprint" not in prof + # urls/collectedAt are vault metadata, not extension profile fields + assert "urls" not in prof + assert "collectedAt" not in prof + + +def test_minimal_vault_profile_empty_fingerprint_omitted() -> None: + prof = minimal_vault_profile({"cookies": [], "fingerprint": {}}) + assert "fingerprint" not in prof + + +# ── VaultSession parses both index and show payloads ───────────────────────── + +def test_vault_session_from_index_payload() -> None: + session = VaultSession.from_payload( + {"id": 8, "label": "S", "created_at": "2026-10-01", "urls": ["https://vc.ru/"]} + ) + assert session.id == 8 + assert session.data == {} + assert session.urls == ["https://vc.ru/"] + + +def test_vault_session_from_show_payload_reads_urls_from_envelope() -> None: + session = VaultSession.from_payload( + {"id": 8, "label": "S", "user_id": 1, "data": SAMPLE_VAULT_DATA} + ) + assert session.data["fingerprint"]["canvasNoise"] == 0.00041478621121495963 + assert session.urls == ["https://vc.ru/education_on_vc_ru/3163579"] + + +# ── ClientVault HTTP surface (mocked httpx.AsyncClient) ────────────────────── + +@pytest.mark.asyncio +async def test_vault_list_parses_paginated_response() -> None: + client = _make_client() + payload = { + "current_page": 1, + "data": [ + {"id": 8, "label": "A", "urls": ["https://vc.ru/"]}, + {"id": 9, "label": "B", "urls": []}, + ], + } + mock_get = AsyncMock(return_value=_make_response(200, payload)) + + with patch("httpx.AsyncClient.get", mock_get): + result = await client.vault.list(per_page=20) + + assert len(result) == 2 + assert result[0].id == 8 + assert result[1].label == "B" + # hit the right endpoint + call_args = mock_get.call_args + assert call_args[0][0] == "https://api.ceki.me/api/vault/sessions" + assert call_args[1]["headers"]["Authorization"] == "Bearer testkey" + + +@pytest.mark.asyncio +async def test_vault_get_returns_decrypted_data() -> None: + client = _make_client() + payload = {"id": 8, "user_id": 1, "label": "S", "data": SAMPLE_VAULT_DATA} + mock_get = AsyncMock(return_value=_make_response(200, payload)) + + with patch("httpx.AsyncClient.get", mock_get): + session = await client.vault.get(8) + + assert session.id == 8 + assert session.data["cookies"][0]["name"] == "auth-refresh-remember" + assert session.data["localStorage"]["https://vc.ru"]["user"] + assert session.urls == ["https://vc.ru/education_on_vc_ru/3163579"] + + +@pytest.mark.asyncio +async def test_vault_create_and_update_post_envelope() -> None: + client = _make_client() + envelope = normalize_profile_for_vault( + { + "fingerprint": None, + "origin": "https://vc.ru", + "cookies": [], + "localStorage": {"k": "v"}, + "sessionStorage": {}, + } + ) + mock_post = AsyncMock(return_value=_make_response(201, {"id": 42})) + mock_put = AsyncMock(return_value=_make_response(200, {"id": 42, "data": envelope})) + + with patch("httpx.AsyncClient.post", mock_post), patch("httpx.AsyncClient.put", mock_put): + created = await client.vault.create(envelope, label="Snapshot") + updated = await client.vault.update(42, envelope, label="Snapshot2") + + assert created == 42 + assert updated.id == 42 + # POST sends {label, data}; PUT overwrites data + post_body = mock_post.call_args[1]["json"] + assert post_body["label"] == "Snapshot" + assert post_body["data"]["urls"] == ["https://vc.ru"] + put_body = mock_put.call_args[1]["json"] + assert put_body["label"] == "Snapshot2" + assert mock_put.call_args[0][0] == "https://api.ceki.me/api/vault/sessions/42" + + +@pytest.mark.asyncio +async def test_vault_delete() -> None: + client = _make_client() + mock_delete = AsyncMock(return_value=_make_response(204, None)) + + with patch("httpx.AsyncClient.delete", mock_delete): + await client.vault.delete(8) + + assert mock_delete.call_args[0][0] == "https://api.ceki.me/api/vault/sessions/8" + + +# ── BrowserVault sugar ────────────────────────────────────────────────────── + +class FakeBrowser: + """Minimal Browser stand-in with a mocked CDP send and a real configure.""" + + def __init__(self, client: Client) -> None: + self._client = client + self.send = AsyncMock(return_value={"result": {"value": "{}"}}) + self.configure_calls: list[dict] = [] + self._vault_session_id: int | None = None + self.profile = BrowserProfile(self) # type: ignore[arg-type] + from ceki_sdk._vault import BrowserVault + self.vault = BrowserVault(self) # type: ignore[arg-type] + + async def configure(self, **kwargs: dict) -> None: # type: ignore[override] + self.configure_calls.append(kwargs) + + +SAMPLE_PROFILE_EXPORT = { + "schema_version": 2, + "fingerprint": {"userAgent": "x"}, + "origin": "https://vc.ru", + "cookies": [{"name": "a", "value": "1", "domain": ".vc.ru"}], + "localStorage": {"user": '{"id":1}'}, + "sessionStorage": {}, +} + + +@pytest.mark.asyncio +async def test_browser_vault_save_creates_new_session() -> None: + client = _make_client() + fb = FakeBrowser(client) + fb.send.side_effect = [ + {"fingerprint": SAMPLE_PROFILE_EXPORT["fingerprint"]}, + {"cookies": SAMPLE_PROFILE_EXPORT["cookies"]}, + {"result": {"value": '{"user":"{\\"id\\":1}"}'}}, + {"result": {"value": "{}"}}, + {"result": {"value": "https://vc.ru"}}, + ] + mock_post = AsyncMock(return_value=_make_response(201, {"id": 77})) + + with patch("httpx.AsyncClient.post", mock_post): + vid = await fb.vault.save(label="Snapshot") + + assert vid == 77 + body = mock_post.call_args[1]["json"] + assert body["label"] == "Snapshot" + assert body["data"]["cookies"][0]["name"] == "a" + assert body["data"]["localStorage"]["https://vc.ru"] # wrapped by origin + + +@pytest.mark.asyncio +async def test_browser_vault_save_overwrites_bound_session() -> None: + client = _make_client() + fb = FakeBrowser(client) + fb.send.side_effect = [ + {"fingerprint": SAMPLE_PROFILE_EXPORT["fingerprint"]}, + {"cookies": SAMPLE_PROFILE_EXPORT["cookies"]}, + {"result": {"value": "{}"}}, + {"result": {"value": "{}"}}, + {"result": {"value": "https://vc.ru"}}, + ] + fb._vault_session_id = 42 + mock_put = AsyncMock(return_value=_make_response(200, {"id": 42, "data": {}})) + + with patch("httpx.AsyncClient.put", mock_put): + vid = await fb.vault.save() + + assert vid == 42 + assert mock_put.call_args[0][0] == "https://api.ceki.me/api/vault/sessions/42" + + +@pytest.mark.asyncio +async def test_browser_vault_restore_sends_configure_with_profile() -> None: + client = _make_client() + fb = FakeBrowser(client) + payload = {"id": 8, "data": SAMPLE_VAULT_DATA} + mock_get = AsyncMock(return_value=_make_response(200, payload)) + + with patch("httpx.AsyncClient.get", mock_get): + await fb.vault.restore(8) + + assert fb.configure_calls, "restore must send session.configure" + cfg = fb.configure_calls[0] + assert cfg["profile"]["cookies"][0]["name"] == "auth-refresh-remember" + # sanitized: no priority/size/sourcePort + assert "priority" not in cfg["profile"]["cookies"][0] + assert cfg["profile"]["localStorage"]["https://vc.ru"]["user"] + # fingerprint promoted to its own config field + assert cfg["fingerprint"]["canvasNoise"] == 0.00041478621121495963 + # bound id recorded for later save-overwrite + assert fb._vault_session_id == 8 + + +@pytest.mark.asyncio +async def test_browser_vault_restore_accepts_raw_envelope() -> None: + client = _make_client() + fb = FakeBrowser(client) + await fb.vault.restore(SAMPLE_VAULT_DATA) + cfg = fb.configure_calls[0] + assert cfg["profile"]["localStorage"]["https://vc.ru"] + assert fb._vault_session_id is None # no API fetch → not bound + + +# ── rent(vault=...) end-to-end through a mocked relay ──────────────────────── + +@pytest.mark.asyncio +async def test_rent_with_vault_restores_profile( + mock_relay: MockRelayServer, monkeypatch: pytest.MonkeyPatch, +) -> None: + import asyncio + + # Force WS transport so the test doesn't wait for P2P/WebRTC handshake. + monkeypatch.setenv("CEKI_FORCE_WS", "1") + url = f"ws://127.0.0.1:{mock_relay.port}" + # vault.get() is an httpx GET — mock it so rent can restore from the API + payload = {"id": 8, "data": SAMPLE_VAULT_DATA} + mock_get = AsyncMock(return_value=_make_response(200, payload)) + + client = await connect("testkey", ConnectOptions(relay_url=url)) + rent_task = asyncio.create_task(client.rent(schedule_id=240, vault=8)) + await asyncio.sleep(0.05) + await mock_relay.send_to_all({"type": "rent_pending", "event_id": "v1", "schedule_id": 240}) + await asyncio.sleep(0.05) + await mock_relay.send_to_all({ + "type": "match", + "event_id": "v1", + "session_id": "v1", + "schedule_id": 240, + "capabilities": {}, + "price_per_min": 0.01, + }) + + with patch("httpx.AsyncClient.get", mock_get): + browser = await asyncio.wait_for(rent_task, timeout=5) + + assert browser.session_id == "v1" + assert browser._vault_session_id == 8 + await asyncio.sleep(0.1) + + configure_msgs = [m for m in mock_relay.received if m.get("type") == "session.configure"] + assert len(configure_msgs) == 1 + assert configure_msgs[0]["session_id"] == "v1" + assert configure_msgs[0]["profile"]["cookies"][0]["name"] == "auth-refresh-remember" + assert configure_msgs[0]["fingerprint"]["canvasNoise"] == 0.00041478621121495963 + + await client.close()