From 14190ad2535c099fda4cf9e88c2a7d83afbaac01 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Sun, 2 Aug 2026 19:26:31 +0000 Subject: [PATCH 1/5] feat(sdk): headless-browser provider mode + docker image Rent out a machine's browser as a public provider: - `ceki provider run`: launches Chromium (Playwright, headed under Xvfb) with the Ceki extension loaded, injects the one-time browser token and brings the browser online with auto-accept enabled. Port of the QA public-browser recipe (provider-runner.js) into the Python SDK; the extension handles the provider WebSocket protocol itself. - api_base normalization: accept both `https://host` and `https://host/api` for CEKI_API_URL / --api-url (request paths are built as base + /api/...). - docker/: provider image (python:3.11-slim + Chromium via playwright + staged extension dist), entrypoint that starts Xvfb and execs the provider as PID 1 for clean SIGTERM shutdown, docker-compose reference, build.sh that stages the extension dist at build time, README. - `[provider]` optional extra (playwright); .dockerignore keeps the staged extension dist out of git. - tests: provider resolve helpers + CLI provider parser (10 tests). --- .dockerignore | 17 ++ .gitignore | 4 + README.md | 46 ++++ ceki_sdk/__init__.py | 3 + ceki_sdk/_provider.py | 529 ++++++++++++++++++++++++++++++++++++++ ceki_sdk/cli.py | 64 +++++ docker/Dockerfile | 55 ++++ docker/README.md | 67 +++++ docker/build.sh | 49 ++++ docker/docker-compose.yml | 21 ++ docker/entrypoint.sh | 25 ++ pyproject.toml | 3 + tests/test_cli.py | 39 +++ tests/test_provider.py | 91 +++++++ 14 files changed, 1013 insertions(+) create mode 100644 .dockerignore create mode 100644 ceki_sdk/_provider.py create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100755 docker/build.sh create mode 100644 docker/docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 tests/test_provider.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05b0e66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Python build artifacts / local files that must not enter the build context. +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +.env +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.graphifyignore +graphify-out/ +.git/ +.gitignore diff --git a/.gitignore b/.gitignore index 535ad65..55f519d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ graphify-out/ .claude/ .graphifyignore .mypy_cache/ + +# Extension dist staged by docker/build.sh at build time — never committed. +docker/extension/ +docker/extension/.gitkeep diff --git a/README.md b/README.md index 26f6ee4..bb1aac5 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,52 @@ Successful commands write a single JSON line to stdout. Errors go to stderr as ` Full reference (with EN+RU): https://browser.ceki.me/docs#cli +### `ceki provider` — rent out your browser + +Turn a machine you control into a **provider**: it runs a real Chromium with the +Ceki extension, injects your browser token and brings the browser online so it +can be rented out as a public browser. The SDK is the launcher — the extension +handles the provider protocol (welcome / accept / CDP / WebRTC) itself. + +```bash +pip install "ceki-sdk[provider]" # extra pulls in Playwright +``` + +```bash +export CEKI_PROVIDER_TOKEN= +export CEKI_PROVIDER_EXT_DIR=/path/to/browser-extension/dist # unpacked ext + +ceki provider run # stays online until stopped +ceki provider run --timeout 600 # run for 10 minutes, then exit +``` + +The token is issued for one specific browser and cannot be reused for another. + +#### Provider environment variables + +| Variable | Required | Purpose | +|---|---|---| +| `CEKI_PROVIDER_TOKEN` | yes | Extension token issued for this browser | +| `CEKI_PROVIDER_EXT_DIR` | yes | Path to the unpacked extension dist (with `manifest.json`) | +| `CEKI_API_URL` | no | API base URL (default `https://api.ceki.me`) | +| `CEKI_PROVIDER_SCHEDULE_ID` | no | Browser/schedule id (usually derived automatically) | + +When no `DISPLAY` is set (e.g. a bare server), the provider re-execs itself +under `xvfb-run` to give Chromium a virtual screen. + +#### Docker + +A thin wrapper image with Python + Chromium + the extension dist and +`ceki provider run` as entrypoint. See `docker/README.md`: + +```bash +./docker/build.sh /path/to/browser-extension/dist # stages ext + builds image +docker run --rm -e CEKI_PROVIDER_TOKEN= ceki/provider:dev +``` + +`docker stop` sends SIGTERM which the provider handles gracefully: the browser +session is closed and the browser goes offline. + ### `ceki contract` — participate in contracts via `/mcp/agent` For AI agents executing tasks inside a contract: list contracts/jobs, post diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 62b6f6e..c95b076 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -19,6 +19,7 @@ ) from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot from ._profile import BrowserProfile +from ._provider import ProviderError, run_provider from .humanize import HumanProfile __version__ = "2.36.1" @@ -38,6 +39,8 @@ "AuthFailed", "ConnectionLost", "ProviderDisconnected", + "run_provider", + "ProviderError", "SessionNotFound", "SessionExpired", "NotOwner", diff --git a/ceki_sdk/_provider.py b/ceki_sdk/_provider.py new file mode 100644 index 0000000..d118c8e --- /dev/null +++ b/ceki_sdk/_provider.py @@ -0,0 +1,529 @@ +"""Provider mode: rent out this machine's browser through Ceki. + +Port of the QA public-browser recipe (ceki-qa/ui-tests/browserlend/provider-runner.js) +into the Python SDK. Deploys a real Chromium (headed, run under Xvfb) with the Ceki +extension loaded, injects the provider token, brings the browser online as a public +provider and keeps the process alive while auto-accepting incoming rentals. + +The SDK does NOT implement the provider WebSocket protocol itself — the extension +does (welcome / accept / cdp / webrtc over its own relay connection). This module is +only the launcher: browser + extension + token handshake + online poll + liveness. + +CLI entry: + ceki provider run [--token TOKEN] [--ext-dir DIR] [--api-url URL] + [--schedule-id ID] [--timeout SECONDS] + +Environment variables: + CEKI_PROVIDER_TOKEN extension token issued for this browser (required) + CEKI_PROVIDER_EXT_DIR path to the unpacked Ceki extension dist + (required unless ``--ext-dir`` is passed) + CEKI_API_URL backend API base (default https://api.ceki.me) + CEKI_PROVIDER_SCHEDULE_ID optional; usually derived from /api/browser/me +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import signal +import sys +import tempfile +import threading +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + +from ._config import default_api_url + +log = logging.getLogger("ceki.provider") + +# Stable extension id (derived from the public manifest key). Used to grant +# incognito access to the unpacked extension in the Chromium profile. +DEFAULT_EXT_ID = "gfionhbdkojjnjpbhlblopoaecdpllhb" + +_CHROME_ARGS = [ + "--disable-extensions-except={ext_dir}", + "--load-extension={ext_dir}", + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", +] + +# JS helpers injected into the extension panel page. ``arg`` is supplied by +# Playwright when the arrow function is evaluated (see page.evaluate). +_HANDSHAKE_JS = r""" +async (args) => { + const result = { ok: false, method: 'storage' }; + try { + await chrome.storage.local.set({ extensionInstanceId: args.instanceId }); + // Legacy path (ZIP builds): the extension resolves the token itself. + const tokenResult = await Promise.race([ + new Promise((resolve) => { + chrome.runtime.sendMessage( + { + type: 'EXT_TOKEN_RECEIVED', + token: args.token, + schedule_id: args.scheduleId || undefined, + }, + (resp) => resolve(chrome.runtime.lastError ? null : resp) + ); + }), + new Promise((r) => setTimeout(() => r(null), 3000)), + ]); + if (tokenResult && tokenResult.ok) { + const goOnline = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { type: 'go-online' }, + (resp) => chrome.runtime.lastError ? reject(chrome.runtime.lastError) : resolve(resp) + ); + }); + result.tokenResult = tokenResult; + result.goOnline = goOnline; + result.ok = true; + result.method = 'legacy'; + return result; + } + // dist builds: validate the token via the API and store it directly. + const resp = await fetch(args.apiBase + '/api/browser/me', { + headers: { 'Authorization': 'Bearer ' + args.token }, + credentials: 'omit', + }); + if (!resp.ok) { + result.error = 'browser_me_' + resp.status; + return result; + } + const browser = await resp.json(); + await chrome.storage.local.set({ + sanctum_token: args.token, + ceki_browser: browser, + paired_at: Date.now(), + incognito_available: true, + auto_accept: true, + }); + result.ok = true; + result.browser = { id: browser.id, online: browser.online }; + return result; + } catch (e) { + result.error = String((e && e.message) || e); + return result; + } +} +""" + +_READ_STORED_JS = r""" +async () => { + const s = await chrome.storage.local.get(['currentToken', 'sanctum_token', 'ceki_browser']); + if (s.currentToken) { + return { token: s.currentToken.token, schedule_id: s.currentToken.schedule_id }; + } + if (s.sanctum_token) { + return { token: s.sanctum_token, schedule_id: s.ceki_browser ? s.ceki_browser.id : null }; + } + return null; +} +""" + +_CLEAR_HEARTBEAT_JS = r""" +async () => { try { await chrome.alarms.clear('ceki-heartbeat'); } catch (e) {} } +""" + +_PATCH_INCOGNITO_JS = r""" +() => { chrome.extension.isAllowedIncognitoAccess = (cb) => cb(true); } +""" + +_PATCH_INCOGNITO_RECHECK_JS = r""" +() => new Promise((resolve) => { + chrome.extension.isAllowedIncognitoAccess((ok) => { + chrome.storage.local.set({ incognito_available: ok, incognito_checked_at: Date.now() }); + resolve(ok); + }); +}) +""" + + +class ProviderError(Exception): + """Raised when the provider cannot be deployed or brought online.""" + + +def _env_int(*names: str) -> int | None: + for name in names: + raw = os.environ.get(name) + if raw: + try: + return int(raw) + except ValueError: + log.warning("ignoring non-integer %s=%r", name, raw) + return None + + +def resolve_token(token: str | None = None) -> str: + """Resolve the provider extension token from arg or environment.""" + value = ( + token + or os.environ.get("CEKI_PROVIDER_TOKEN") + or os.environ.get("PROVIDER_TOKEN") + or "" + ).strip() + if not value: + raise ProviderError( + "Provider token is required: set CEKI_PROVIDER_TOKEN or pass --token" + ) + return value + + +def resolve_ext_dir(explicit: str | None = None) -> str: + """Resolve the extension dist directory. + + Order: ``--ext-dir`` arg, ``CEKI_PROVIDER_EXT_DIR``, ``CEKI_EXT_DIR``, + then a bundled copy next to the package (``ceki_sdk/provider_assets/extension``). + """ + candidates = [ + explicit, + os.environ.get("CEKI_PROVIDER_EXT_DIR"), + os.environ.get("CEKI_EXT_DIR"), + str(Path(__file__).resolve().parent / "provider_assets" / "extension"), + ] + for cand in candidates: + if cand and Path(cand, "manifest.json").is_file(): + return os.path.abspath(cand) + raise ProviderError( + "Ceki extension dist not found. Pass --ext-dir or set CEKI_PROVIDER_EXT_DIR " + "to the unpacked extension directory (must contain manifest.json)." + ) + + +def resolve_api_base(explicit: str | None = None) -> str: + value = explicit or os.environ.get("CEKI_API_URL") or default_api_url() + value = value.rstrip("/") + # Accept both "https://host" and "https://host/api" (QA profiles use the + # latter). Provider request paths are built as f"{base}/api/...", so the + # base must be the host root. + if value.endswith("/api"): + value = value[: -len("/api")] + return value + + +def _ensure_display() -> None: + """Run under Xvfb when no display is available (e.g. inside a Docker container). + + Re-executes the current process through ``xvfb-run -a`` so the headed browser + has a virtual screen. Never returns when a re-exec happens. + """ + if os.environ.get("DISPLAY"): + return + xvfb = shutil.which("xvfb-run") + if not xvfb: + raise ProviderError( + "No DISPLAY is set and xvfb-run is not installed. " + "Run under a display (e.g. xvfb-run -a ceki provider run) or install xvfb." + ) + # Rebuild the command for how we were launched. ``python -m`` sets argv[0] + # to the module file (not executable on its own), so re-invoke via sys.executable. + main_mod = sys.modules.get("__main__") + spec = getattr(main_mod, "__spec__", None) if main_mod else None + if spec and getattr(spec, "name", None): + args = [sys.executable, "-m", spec.name, *sys.argv[1:]] + else: + args = list(sys.argv) + log.info("no DISPLAY set — re-execing under xvfb-run: %s", " ".join(args)) + os.execvp(xvfb, [xvfb, "-a", *args]) + + +def _setup_logging(verbose: bool = False) -> None: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")) + logger = logging.getLogger("ceki.provider") + logger.handlers.clear() + logger.addHandler(handler) + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + logger.propagate = False + + +def _install_signal_handlers(stop: threading.Event) -> None: + def handler(signum: int, _frame: Any) -> None: # pragma: no cover - signal path + log.info("signal %s received, shutting down", signum) + stop.set() + + for sig in (signal.SIGTERM, signal.SIGINT): + try: + signal.signal(sig, handler) + except (ValueError, OSError): # pragma: no cover - non-main-thread edge + pass + + +@dataclass +class ProviderContext: + browser_context: Any + profile_dir: str + extension_id: str + token: str + schedule_id: int | None + api_base: str + + +def _discover_ext_id(context: Any, wait_s: float = 15.0) -> str | None: + deadline = time.time() + wait_s + while time.time() < deadline: + for sw in context.service_workers: + m = re.search(r"chrome-extension://([a-z]+)/", sw.url) + if m: + return m.group(1) + time.sleep(1) + return None + + +def _open_panel(browser_context: Any, ext_id: str) -> Any | None: + page = browser_context.new_page() + for path in ("panel/index.html", "panel.html", "popup.html"): + try: + page.goto( + f"chrome-extension://{ext_id}/{path}", + wait_until="domcontentloaded", + timeout=10_000, + ) + return page + except Exception: + continue + page.close() + return None + + +def _browser_status(api_base: str, token: str) -> str: + """Poll /api/browser/me once. Returns ``online`` / ``offline`` / status string.""" + try: + resp = httpx.get( + f"{api_base}/api/browser/me", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + if data.get("online") is True or data.get("status") == "online": + return "online" + return data.get("status") or ("offline" if not data.get("online") else "online") + log.info("api check: status=%s", resp.status_code) + except httpx.HTTPError as exc: + log.info("api check failed: %s", exc) + return "offline" + + +def _poll_online( + api_base: str, + token: str, + attempts: int = 8, + interval: float = 5.0, +) -> str: + for i in range(attempts): + status = _browser_status(api_base, token) + if status == "online": + return status + log.info("provider not online yet: status=%s (attempt %d/%d)", status, i + 1, attempts) + if i < attempts - 1: + time.sleep(interval) + return "offline" + + +def _launch_provider( + playwright: Any, + *, + token: str, + ext_dir: str, + api_base: str, + schedule_id: int | None, +) -> ProviderContext: + chromium = playwright.chromium + profile_dir = tempfile.mkdtemp(prefix="ceki-provider-") + default_dir = Path(profile_dir) / "Default" + default_dir.mkdir(parents=True, exist_ok=True) + + chrome_args = [a.format(ext_dir=ext_dir) for a in _CHROME_ARGS] + + def launch() -> Any: + return chromium.launch_persistent_context( + profile_dir, + headless=False, + args=chrome_args, + viewport={"width": 1280, "height": 720}, + ignore_https_errors=True, + ) + + # Two-launch: install the extension, grant incognito access post-install + # (the preseeded Preferences are overwritten by Chromium on install), then + # relaunch with incognito permission persisted in the profile. + log.info("two-launch: install phase") + c1 = launch() + ext_id = _discover_ext_id(c1) + c1.close() + if not ext_id: + shutil.rmtree(profile_dir, ignore_errors=True) + raise ProviderError("Could not discover extension ID in install phase") + + try: + prefs_path = default_dir / "Preferences" + prefs = json.loads(prefs_path.read_text()) + settings = prefs.setdefault("extensions", {}).setdefault("settings", {}) + entry = settings.setdefault(ext_id, {}) + entry["incognito"] = True + entry["state"] = 1 + prefs_path.write_text(json.dumps(prefs)) + log.info("two-launch: incognito granted for %s (post-install)", ext_id) + except Exception as exc: + log.warning("two-launch: Preferences edit failed: %s", exc) + + browser_context = launch() + discovered = _discover_ext_id(browser_context) + if discovered: + ext_id = discovered + if discovered != DEFAULT_EXT_ID: + log.warning("extension id %s differs from expected %s", discovered, DEFAULT_EXT_ID) + + # Headless hosts have no UI toggle for incognito access — force it on. + if browser_context.service_workers: + try: + sw = browser_context.service_workers[0] + sw.evaluate(_PATCH_INCOGNITO_JS) + sw.evaluate(_PATCH_INCOGNITO_RECHECK_JS) + except Exception as exc: + log.warning("incognito patch failed: %s", exc) + time.sleep(1) + + popup = _open_panel(browser_context, ext_id) + if popup is None: + browser_context.close() + shutil.rmtree(profile_dir, ignore_errors=True) + raise ProviderError("Could not open the extension panel/popup page") + + handshake = popup.evaluate(_HANDSHAKE_JS, { + "token": token, + "scheduleId": schedule_id, + "instanceId": str(uuid.uuid4()), + "apiBase": api_base, + }) + log.info("handshake: %s", json.dumps(handshake)) + if not handshake or not handshake.get("ok"): + browser_context.close() + shutil.rmtree(profile_dir, ignore_errors=True) + raise ProviderError(f"Token handshake failed: {handshake}") + + stored = popup.evaluate(_READ_STORED_JS) + if not stored: + browser_context.close() + shutil.rmtree(profile_dir, ignore_errors=True) + raise ProviderError("No token found in extension storage after handshake") + token = stored.get("token") or token + schedule_id = stored.get("schedule_id") or schedule_id + log.info( + "post-handshake: schedule_id=%s token=%s...", + schedule_id, + str(token)[:12], + ) + + log.info("waiting for relay connection...") + time.sleep(5) + try: + popup.evaluate(_CLEAR_HEARTBEAT_JS) + except Exception: + pass + + status = _poll_online(api_base, token, attempts=8, interval=5) + log.info("provider status: %s", status) + if status != "online": + browser_context.close() + shutil.rmtree(profile_dir, ignore_errors=True) + raise ProviderError(f"Extension did not come online: {status}") + + return ProviderContext( + browser_context=browser_context, + profile_dir=profile_dir, + extension_id=ext_id, + token=token, + schedule_id=schedule_id, + api_base=api_base, + ) + + +def _keep_alive(ctx: ProviderContext, stop: threading.Event, timeout: int | None) -> int: + log.info( + "provider READY: extension=%s schedule_id=%s — staying alive, auto-accept enabled", + ctx.extension_id, + ctx.schedule_id, + ) + started = time.time() + last_status = 0.0 + while not stop.is_set(): + elapsed = time.time() - started + if timeout and elapsed >= timeout: + log.info("timeout reached (%ss), shutting down", timeout) + break + if elapsed - last_status >= 30: + last_status = elapsed + status = _browser_status(ctx.api_base, ctx.token) + log.info("[heartbeat] online=%s elapsed=%ds", status, int(elapsed)) + time.sleep(1) + return 0 + + +def run_provider( + *, + token: str | None = None, + ext_dir: str | None = None, + api_base: str | None = None, + schedule_id: int | None = None, + timeout: int | None = None, + verbose: bool = False, +) -> int: + """Deploy a browser provider and keep it online until stopped. + + Returns a process exit code (0 on clean shutdown). + """ + _setup_logging(verbose) + _ensure_display() + token_value = resolve_token(token) + ext_dir_value = resolve_ext_dir(ext_dir) + api_base_value = resolve_api_base(api_base) + schedule_id_value = schedule_id or _env_int( + "CEKI_PROVIDER_SCHEDULE_ID", "PROVIDER_SCHEDULE_ID" + ) + + log.info( + "provider: api_base=%s ext_dir=%s schedule_id=%s", + api_base_value, + ext_dir_value, + schedule_id_value, + ) + + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: # pragma: no cover - env-dependent + raise ProviderError( + "Playwright is required for provider mode. " + "Install it with: pip install 'ceki-sdk[provider]'" + ) from exc + + stop: threading.Event = threading.Event() + _install_signal_handlers(stop) + + with sync_playwright() as playwright: + ctx = _launch_provider( + playwright, + token=token_value, + ext_dir=ext_dir_value, + api_base=api_base_value, + schedule_id=schedule_id_value, + ) + try: + return _keep_alive(ctx, stop=stop, timeout=timeout) + finally: + try: + ctx.browser_context.close() + except Exception: + pass + shutil.rmtree(ctx.profile_dir, ignore_errors=True) + return 0 diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 8bed04a..1a3cb36 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -232,6 +232,30 @@ def _cmd_daemon(args: argparse.Namespace) -> int: return 1 +def _cmd_provider(args: argparse.Namespace) -> int: + """Deploy a browser provider (``ceki provider run``). + + Long-running: launches Chromium + the Ceki extension, injects the token, + brings the browser online and stays alive until SIGTERM/SIGINT. + """ + from ._provider import ProviderError, run_provider + + try: + return run_provider( + token=args.token, + ext_dir=args.ext_dir, + api_base=args.api_url, + schedule_id=args.schedule_id, + timeout=args.timeout, + verbose=args.verbose, + ) + except ProviderError as e: + _err(str(e), "provider") + return 1 + except KeyboardInterrupt: + return 130 + + async def _cmd_rent(args: argparse.Namespace) -> None: # Auto-start daemon on rent — subsequent commands use the persistent WS if not _ensure_daemon(): @@ -1536,6 +1560,43 @@ def build_parser() -> argparse.ArgumentParser: dsub.add_parser("stop", help="Stop daemon (SIGTERM)") dsub.add_parser("status", help="Check daemon status") + # ── provider subcommand ─────────────────────────────────────────── + p_provider = sub.add_parser( + "provider", + help="Run a browser provider — rent out this machine's browser", + ) + psub = p_provider.add_subparsers(dest="provider_action", required=True) + p_run = psub.add_parser( + "run", + help=( + "Deploy a browser provider (Chromium + Ceki extension + token) " + "and keep it online, auto-accepting rentals" + ), + ) + p_run.add_argument( + "--token", + help="Provider extension token (default: $CEKI_PROVIDER_TOKEN)", + ) + p_run.add_argument( + "--ext-dir", + help="Path to the unpacked Ceki extension dist (default: $CEKI_PROVIDER_EXT_DIR)", + ) + p_run.add_argument( + "--api-url", + help="Backend API base URL (default: $CEKI_API_URL or https://api.ceki.me)", + ) + p_run.add_argument( + "--schedule-id", + type=int, + help="Browser/schedule ID (default: derived from /api/browser/me)", + ) + p_run.add_argument( + "--timeout", + type=int, + help="Run for N seconds then exit (default: run until stopped)", + ) + p_run.add_argument("--verbose", action="store_true", help="Verbose logging") + return parser @@ -1577,6 +1638,9 @@ def main() -> None: if args.command == "daemon": sys.exit(_cmd_daemon(args)) + if args.command == "provider": + sys.exit(_cmd_provider(args)) + handler = handlers.get(args.command) if not handler: _err(f"Unknown command: {args.command}") diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bec4fbf --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 +# +# Ceki headless-browser provider image. +# +# Thin wrapper around the python-sdk "provider" mode: +# * Chromium (Playwright) — the rented public browser +# * the ceki browser extension (dist) — the provider agent inside the browser +# * `ceki provider run` — launches Chromium + extension, injects the token and +# keeps the browser online until a renter connects or the process stops +# +# The browser token is passed at runtime via CEKI_PROVIDER_TOKEN. +# +# Build context = python-sdk repo root. `docker/build.sh` stages the extension +# dist into docker/extension/ before `docker build`. + +FROM python:3.11-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ + DISPLAY=:99 + +WORKDIR /opt/ceki + +# Chromium runtime libraries + Xvfb virtual display (Chromium needs a display to +# run as a "visible" provider browser; Xvfb provides it headlessly). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 \ + libglib2.0-0 libgdk-pixbuf-2.0-0 xvfb xauth x11-utils ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Python SDK (provider extra pulls in playwright). +COPY pyproject.toml README.md ./ +COPY ceki_sdk/ ./ceki_sdk/ +RUN pip install --no-cache-dir '.[provider]' + +# Chromium pinned by Playwright (used only by the provider browser). +RUN python -m playwright install chromium + +# Bundled browser extension dist (staged into docker/extension by build.sh). +COPY docker/extension/ /opt/ceki/extension/ + +# CEKI_PROVIDER_TOKEN is supplied at runtime (docker run -e ...). +ENV CEKI_PROVIDER_EXT_DIR=/opt/ceki/extension \ + CEKI_PROVIDER_LOG_LEVEL=INFO + +# Entrypoint starts Xvfb (if needed) and then execs the command as PID 1 so a +# `docker stop` (SIGTERM to PID 1) reaches the provider for a clean shutdown. +COPY docker/entrypoint.sh /usr/local/bin/ceki-entrypoint +RUN chmod +x /usr/local/bin/ceki-entrypoint + +ENTRYPOINT ["/usr/local/bin/ceki-entrypoint"] +CMD ["ceki", "provider", "run"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..ff8a5e4 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,67 @@ +# Headless browser provider (Docker) + +Run a ceki **provider** browser from Docker: the container starts Chromium with +the ceki extension installed, injects your browser token and keeps the browser +online so it can be rented out as a public browser. + +This is a thin wrapper around the SDK's provider mode (`ceki provider run`, +see `ceki_sdk/_provider.py`). + +## Prerequisites + +- Docker +- A **provider token** — create it in your account dashboard ("call a browser" + flow). One token = one browser = one container. + +## Build + +The image bundles the browser-extension dist, so the build script stages it +from a local clone of the extension repo before running `docker build`: + +```bash +./docker/build.sh # finds browser-extension/dist automatically +./docker/build.sh /path/to/dist # ...or point at the dist explicitly +``` + +## Run + +```bash +docker run --rm \ + -e CEKI_PROVIDER_TOKEN= \ + ceki/provider:dev +``` + +The container starts an Xvfb virtual display, launches Chromium with the +extension, brings the browser **online** and keeps it there until a renter +connects or the container is stopped. + +### docker compose + +```bash +export CEKI_PROVIDER_TOKEN= +docker compose -f docker/docker-compose.yml up -d --build +docker compose -f docker/docker-compose.yml logs -f provider +docker compose -f docker/docker-compose.yml stop provider +``` + +## Configuration + +| Env var | Default | Description | +|---|---|---| +| `CEKI_PROVIDER_TOKEN` | — | **Required.** One-time browser token from your dashboard. | +| `CEKI_API_URL` | `https://api.ceki.me` | API base URL. | +| `CEKI_PROVIDER_EXT_DIR` | `/opt/ceki/extension` | Extension dist inside the image. | +| `DISPLAY` | `:99` | X display for the virtual screen. | + +## Stopping / cleanup + +`docker stop` sends SIGTERM which the provider handles gracefully: the rented +browser is closed and the browser goes offline. `docker compose stop` does the +same. + +## Notes + +- One browser per container. To run several providers, start several containers, + each with its own token. +- The token is bound to the specific browser it was issued for; it cannot be + reused for another browser. diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 0000000..e9d0faf --- /dev/null +++ b/docker/build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Build the ceki headless-browser provider image. +# +# Run from the python-sdk repo root (or pass the repo root as $1): +# ./docker/build.sh +# +# What it does: +# 1. Stages the browser-extension dist into docker/extension/ +# (docker/extension/ is git-ignored; the dist never lands in git). +# 2. Runs `docker build` with the python-sdk repo as context. +# +# The extension dist is taken from the sibling clone of browser-extension: +# $CEKI_EXT_DIST (default: ../../browser-extension/dist) +# +# Optionally: ./docker/build.sh /path/to/browser-extension/dist + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${CEKI_IMAGE:-ceki/provider:dev}" +EXT_SRC="${1:-${CEKI_EXT_DIST:-}}" + +if [ -z "${EXT_SRC:-}" ]; then + # Try a couple of conventional locations for the extension clone. + for cand in "$ROOT/../browser-extension/dist" "$HOME/browser-extension/dist"; do + if [ -f "$cand/manifest.json" ]; then + EXT_SRC="$cand" + break + fi + done +fi + +if [ -z "${EXT_SRC:-}" ] || [ ! -f "$EXT_SRC/manifest.json" ]; then + echo "error: extension dist not found." >&2 + echo " pass the dist dir explicitly: $0 /path/to/browser-extension/dist" >&2 + echo " or set CEKI_EXT_DIST." >&2 + exit 1 +fi + +echo "[ceki-provider] staging extension dist: $EXT_SRC -> docker/extension/" +rm -rf "$ROOT/docker/extension" +mkdir -p "$ROOT/docker/extension" +cp -a "$EXT_SRC"/. "$ROOT/docker/extension/" + +echo "[ceki-provider] building image: $IMAGE" +docker build -t "$IMAGE" -f "$ROOT/docker/Dockerfile" "$ROOT" + +echo "[ceki-provider] done: $IMAGE" +echo " run: docker run --rm -e CEKI_PROVIDER_TOKEN= $IMAGE" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..cb6f0fd --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,21 @@ +# Docker-compose reference for the ceki headless-browser provider. +# Token must be provided at runtime (never commit it here). +# +# docker compose -f docker/docker-compose.yml up --build -d +# docker compose -f docker/docker-compose.yml logs -f provider +# docker compose -f docker/docker-compose.yml stop provider + +services: + provider: + build: + context: .. + dockerfile: docker/Dockerfile + image: ceki/provider:dev + environment: + # REQUIRED: the one-time browser token from your account dashboard. + - CEKI_PROVIDER_TOKEN=${CEKI_PROVIDER_TOKEN:?set CEKI_PROVIDER_TOKEN} + # Optional: override the API base URL (defaults to https://api.ceki.me). + - CEKI_API_URL=${CEKI_API_URL:-} + - DISPLAY=:99 + # One provider browser per container. Scale to run more (each needs its own token). + restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..eb3fdbd --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Ceki headless-provider container entrypoint. +# +# Starts a virtual display (Xvfb) if none is already running, then execs the +# real command. `exec` is important: the provider process replaces this script +# and becomes PID 1, so `docker stop` (SIGTERM to PID 1) reaches the provider +# directly and the browser session is shut down cleanly (rented browser goes +# offline, no orphaned processes). +# +# DISPLAY can be overridden by the user (e.g. to attach to an external X +# server / x11vnc). + +set -e + +if [ -z "${DISPLAY:-}" ]; then + export DISPLAY=:99 +fi + +# Start Xvfb if it is not already up on our display. +if ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then + echo "[ceki-provider] starting Xvfb on ${DISPLAY}" + Xvfb "${DISPLAY}" -screen 0 1280x720x24 -nolisten tcp >/tmp/xvfb.log 2>&1 & +fi + +exec "$@" diff --git a/pyproject.toml b/pyproject.toml index b1ced32..9009397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5", "mypy>=1.10"] +# Browser provider mode (ceki provider run / docker image). Playwright drives +# Chromium; it is intentionally an extra so the base SDK stays dependency-light. +provider = ["playwright>=1.40"] [project.urls] Homepage = "https://ceki.me" diff --git a/tests/test_cli.py b/tests/test_cli.py index 043bef2..dbe4f36 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -475,3 +475,42 @@ async def test_snapshot_returns_data(): assert snap.screenshot == png_data assert snap.chat == [] assert snap.ts is not None + + +# ────────────────────────────────────────────────────────────────────────── +# Provider command parser +# ────────────────────────────────────────────────────────────────────────── + + +def test_parser_provider_run(): + parser = build_parser() + args = parser.parse_args( + ["provider", "run", "--token", "tok-1", "--ext-dir", "/ext/dist", + "--api-url", "https://api.ceki.me", "--schedule-id", "7", "--timeout", "90"] + ) + assert args.command == "provider" + assert args.provider_action == "run" + assert args.token == "tok-1" + assert args.ext_dir == "/ext/dist" + assert args.api_url == "https://api.ceki.me" + assert args.schedule_id == 7 + assert args.timeout == 90 + assert args.verbose is False + + +def test_parser_provider_run_defaults(): + parser = build_parser() + args = parser.parse_args(["provider", "run"]) + assert args.command == "provider" + assert args.token is None + assert args.ext_dir is None + assert args.api_url is None + assert args.schedule_id is None + assert args.timeout is None + assert args.verbose is False + + +def test_parser_provider_requires_subcommand(): + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["provider"]) diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 0000000..a0fbd90 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os + +import pytest + +from ceki_sdk._provider import ( + ProviderError, + resolve_api_base, + resolve_ext_dir, + resolve_token, +) + + +def _clean_provider_env(monkeypatch): + for key in ( + "CEKI_PROVIDER_TOKEN", + "PROVIDER_TOKEN", + "CEKI_PROVIDER_EXT_DIR", + "CEKI_EXT_DIR", + "CEKI_API_URL", + ): + monkeypatch.delenv(key, raising=False) + + +def test_resolve_token_from_arg(monkeypatch): + _clean_provider_env(monkeypatch) + assert resolve_token("tok-1") == "tok-1" + + +def test_resolve_token_from_env(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("CEKI_PROVIDER_TOKEN", "env-tok") + assert resolve_token(None) == "env-tok" + assert resolve_token("") == "env-tok" + + +def test_resolve_token_required(monkeypatch): + _clean_provider_env(monkeypatch) + with pytest.raises(ProviderError): + resolve_token(None) + with pytest.raises(ProviderError): + resolve_token(" ") + + +def test_resolve_ext_dir_from_arg(tmp_path, monkeypatch): + _clean_provider_env(monkeypatch) + ext = tmp_path / "ext" + ext.mkdir() + (ext / "manifest.json").write_text("{}") + assert resolve_ext_dir(str(ext)) == str(ext) + + +def test_resolve_ext_dir_from_env(tmp_path, monkeypatch): + _clean_provider_env(monkeypatch) + ext = tmp_path / "ext" + ext.mkdir() + (ext / "manifest.json").write_text("{}") + monkeypatch.setenv("CEKI_PROVIDER_EXT_DIR", str(ext)) + assert resolve_ext_dir(None) == str(ext) + + +def test_resolve_ext_dir_missing(monkeypatch): + _clean_provider_env(monkeypatch) + with pytest.raises(ProviderError): + resolve_ext_dir(None) + with pytest.raises(ProviderError): + resolve_ext_dir("/nonexistent/path") + + +def test_resolve_api_base_default(monkeypatch): + _clean_provider_env(monkeypatch) + assert resolve_api_base(None) == "https://api.ceki.me" + + +def test_resolve_api_base_env(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("CEKI_API_URL", "https://api.example.test/") + assert resolve_api_base(None) == "https://api.example.test" + + +def test_resolve_api_base_strips_api_suffix(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("CEKI_API_URL", "https://api.example.test/api") + assert resolve_api_base(None) == "https://api.example.test" + + +def test_resolve_api_base_strips_api_suffix_trailing_slash(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("CEKI_API_URL", "https://api.example.test/api/") + assert resolve_api_base(None) == "https://api.example.test" From d544767d7c590b04816f1c0aa7f21543f99840fa Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Mon, 3 Aug 2026 08:04:49 +0000 Subject: [PATCH 2/5] feat(sdk): provider self-fingerprint fixes (tz match, fake audio devices, automation flag) --- ceki_sdk/_provider.py | 39 +++++++++++++++++++++++++++++++++++++++ docker/docker-compose.yml | 3 +++ docker/entrypoint.sh | 7 +++++++ tests/test_provider.py | 12 ++++++++++++ 4 files changed, 61 insertions(+) diff --git a/ceki_sdk/_provider.py b/ceki_sdk/_provider.py index d118c8e..759e99b 100644 --- a/ceki_sdk/_provider.py +++ b/ceki_sdk/_provider.py @@ -54,6 +54,15 @@ "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", + # ev-5421 self-fingerprint quality fixes: + # fake media devices make AudioContext non-empty (removes the + # "Audio context empty (headless indicator)" consistency penalty) + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + # kill the remaining "Chrome is being controlled" automation marker + "--disable-blink-features=AutomationControlled", + # deterministic, consistent accept-language/locale + "--lang=en-US", ] # JS helpers injected into the extension panel page. ``arg`` is supplied by @@ -268,6 +277,34 @@ class ProviderContext: api_base: str +def _ensure_timezone() -> None: + """Ensure a non-UTC TZ is set so the provider browser matches the IP geolocation. + + Priority: the ``TZ`` env var (docker-compose passes the host TZ via + ``TZ=${TZ:-}``), then ``/etc/timezone`` (Debian images). Chromium launched + by Playwright inherits ``TZ`` and reports the matching local time, which + removes the "IP tz != browser tz" leak penalty in the self-fingerprint scan. + Best effort — never raises, never hardcodes a region. + """ + tz = os.environ.get("TZ") + if not tz: + try: + tzfile = Path("/etc/timezone") + if tzfile.is_file(): + tz = tzfile.read_text().strip() + except Exception: + tz = None + if not tz: + return + os.environ["TZ"] = tz + try: + if hasattr(time, "tzset"): + time.tzset() + except Exception: + pass + log.info("provider timezone: %s", tz) + + def _discover_ext_id(context: Any, wait_s: float = 15.0) -> str | None: deadline = time.time() + wait_s while time.time() < deadline: @@ -338,6 +375,8 @@ def _launch_provider( api_base: str, schedule_id: int | None, ) -> ProviderContext: + _ensure_timezone() + chromium = playwright.chromium profile_dir = tempfile.mkdtemp(prefix="ceki-provider-") default_dir = Path(profile_dir) / "Default" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cb6f0fd..c034b81 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,9 @@ services: - CEKI_PROVIDER_TOKEN=${CEKI_PROVIDER_TOKEN:?set CEKI_PROVIDER_TOKEN} # Optional: override the API base URL (defaults to https://api.ceki.me). - CEKI_API_URL=${CEKI_API_URL:-} + # Inherit the host timezone so the provider browser's local time matches + # the IP geolocation (ev-5421 self-fingerprint consistency). + - TZ=${TZ:-} - DISPLAY=:99 # One provider browser per container. Scale to run more (each needs its own token). restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index eb3fdbd..cb66440 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,6 +12,13 @@ set -e +# Inherit a non-UTC timezone so the provider browser matches the IP geolocation +# (ev-5421). Priority: TZ env (compose passes ${TZ:-}) → /etc/timezone. +if [ -z "${TZ:-}" ] && [ -f /etc/timezone ]; then + TZ="$(cat /etc/timezone)" + export TZ +fi + if [ -z "${DISPLAY:-}" ]; then export DISPLAY=:99 fi diff --git a/tests/test_provider.py b/tests/test_provider.py index a0fbd90..39f3798 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -89,3 +89,15 @@ def test_resolve_api_base_strips_api_suffix_trailing_slash(monkeypatch): _clean_provider_env(monkeypatch) monkeypatch.setenv("CEKI_API_URL", "https://api.example.test/api/") assert resolve_api_base(None) == "https://api.example.test" + + +def test_chrome_args_include_ev5421_quality_flags(): + from ceki_sdk._provider import _CHROME_ARGS + joined = " ".join(_CHROME_ARGS) + # audio consistency + assert "--use-fake-ui-for-media-stream" in joined + assert "--use-fake-device-for-media-stream" in joined + # automation marker + assert "--disable-blink-features=AutomationControlled" in joined + # language/locale consistency + assert "--lang=en-US" in joined From 7807f582185cc860d45b61b8470c12909c2ae77e Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Tue, 11 Aug 2026 15:08:26 +0000 Subject: [PATCH 3/5] fix(sdk): daemon void-command fallback and shared client WS reuse - cli: _daemon_request returns (ok, result) tuple; callers branch on 'ok', a null result from a void command is success, not a failure signal - daemon: reuse one shared Client per api_key (single WS, no per-rent leak); drop session from registry on session.ended; disconnect shared client only when the last session ends - tests: unit regression for CLI void-command fallback and daemon WS reuse --- ceki_sdk/_client.py | 14 +++ ceki_sdk/cli.py | 152 ++++++++++++++++++------- ceki_sdk/daemon.py | 108 ++++++++++++++---- tests/test_cli.py | 116 +++++++++++++++++++ tests/test_daemon.py | 266 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 57 deletions(-) create mode 100644 tests/test_daemon.py diff --git a/ceki_sdk/_client.py b/ceki_sdk/_client.py index 73e2fa0..bf08962 100644 --- a/ceki_sdk/_client.py +++ b/ceki_sdk/_client.py @@ -5,6 +5,7 @@ import logging import os import time +from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any import httpx @@ -67,6 +68,11 @@ def __init__( self._closed = False self._stashed_first_frame: str | None = None + # Optional hook invoked on ``session.ended``/``session_end``. The + # daemon uses it to drop the session from its registry and close the + # shared WebSocket once the last session for a client is gone. + self._on_session_ended: Callable[[str], Awaitable[None]] | None = None + # P2P WebRTC transport (primary, WS = fallback) self._p2p: WebRTCTransport | None = None self._p2p_init_lock = asyncio.Lock() @@ -508,6 +514,14 @@ async def _dispatch(self, msg: dict[str, Any]) -> None: browser = self._active_browsers.get(session_id) if browser: await browser._on_session_ended(msg) + # Notify the daemon so it can drop the session from its registry and + # close the shared WS once the last session for this client is gone. + hook = self._on_session_ended + if hook is not None: + try: + await hook(session_id) + except Exception as exc: + log.error("session.ended hook failed: %s", exc) return if mtype == "session.provider_disconnected": session_id = msg.get("session_id", "") diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index 1a3cb36..fcbba58 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -112,19 +112,26 @@ async def _daemon_request( path: str, params: dict[str, Any] | None = None, timeout: float = 120.0, -) -> Any: +) -> tuple[bool, Any]: """Send an IPC request to a running daemon. - Returns ``None`` when the daemon is not running (clean fallback for the - caller). Raises ``CekiError`` when the daemon *was* expected to be + Returns ``(False, None)`` when the daemon is not running (clean fallback + for the caller). Raises ``CekiError`` when the daemon *was* expected to be reachable but isn't — the caller shows the error to the user instead of falling back to one-shot mode. + Returns ``(True, result)`` on success. ``result`` may be ``None`` for + void commands (navigate/click/type/scroll/switch_tab/configure/stop/ + profile_import) — a null result is a SUCCESS, not a failure signal. The + caller MUST branch on the first tuple element, never on ``result`` alone, + otherwise a successful void command falls through to the one-shot resume + fallback which disconnects and kills the rented session. + The function checks ``PID_FILE`` first as a fast-path; if absent there is no running daemon. If present but unreachable we clean the stale file. """ if not PID_FILE.exists(): - return None # daemon not running → clean fallback + return False, None # daemon not running → clean fallback port = daemon_port() url = f"http://{DAEMON_HOST}:{port}{path}" @@ -139,10 +146,10 @@ async def _daemon_request( body = resp.json() if not body.get("ok"): raise CekiError(body.get("error", "daemon error")) - return body.get("result") + return True, body.get("result") except httpx.ConnectError: PID_FILE.unlink(missing_ok=True) - return None # stale PID → clean fallback + return False, None # stale PID → clean fallback except httpx.TimeoutException: raise CekiError("daemon not responding (timeout), start daemon first") except httpx.HTTPError as e: @@ -265,12 +272,12 @@ 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 try: - result = await _daemon_request("/rent", { + ok, result = await _daemon_request("/rent", { "schedule": args.schedule, "mode": args.mode, "fingerprint_from": fp_from, }) - if result is not None: + if ok: sid = result["session_id"] save_session(sid, { "session_id": sid, @@ -319,8 +326,8 @@ async def _resume_browser(api_key: str, session_id: str): async def _cmd_snapshot(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/snapshot", {"session_id": args.session_id}) - if result is not None: + ok, result = await _daemon_request("/snapshot", {"session_id": args.session_id}) + if ok: png_bytes = base64.b64decode(result["screenshot"]) if result.get("screenshot") else b"" out_path = args.output with open(out_path, "wb") as f: @@ -364,12 +371,12 @@ def _human_flag(args: argparse.Namespace) -> bool | None: async def _cmd_navigate(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/navigate", { + ok, result = await _daemon_request("/navigate", { "session_id": args.session_id, "url": args.url, "human": _human_flag(args), }) - if result is not None: + if ok: _out({"ok": True}) return except CekiError as e: @@ -390,13 +397,13 @@ async def _cmd_navigate(args: argparse.Namespace) -> None: async def _cmd_click(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/click", { + ok, result = await _daemon_request("/click", { "session_id": args.session_id, "x": args.x, "y": args.y, "human": _human_flag(args), }) - if result is not None: + if ok: _out({"ok": True, "pointer": [args.x, args.y]}) return except CekiError as e: @@ -421,13 +428,13 @@ async def _cmd_type(args: argparse.Namespace) -> None: # --natural is a no-op alias kept for backwards compatibility. # Try daemon IPC try: - result = await _daemon_request("/type", { + ok, result = await _daemon_request("/type", { "session_id": args.session_id, "text": args.text, "selector": args.selector, "human": _human_flag(args), }) - if result is not None: + if ok: _out({"ok": True}) return except CekiError as e: @@ -448,14 +455,14 @@ async def _cmd_type(args: argparse.Namespace) -> None: async def _cmd_scroll(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/scroll", { + ok, result = await _daemon_request("/scroll", { "session_id": args.session_id, "x": args.x, "y": args.y, "dy": args.dy, "human": _human_flag(args), }) - if result is not None: + if ok: _out({"ok": True}) return except CekiError as e: @@ -477,21 +484,21 @@ async def _cmd_chat(args: argparse.Namespace) -> None: # Try daemon IPC try: if args.chat_action == "send": - result = await _daemon_request("/chat/send", { + ok, result = await _daemon_request("/chat/send", { "session_id": args.session_id, "text": args.text, }) - if result is not None: + if ok: _out({"ok": True, "message_id": result.get("message_id")}) return elif args.chat_action == "next": last_seen = get_last_seen_ts(args.session_id) - result = await _daemon_request("/chat/next", { + ok, result = await _daemon_request("/chat/next", { "session_id": args.session_id, "timeout": args.timeout, "since": last_seen, }) - if result is not None: + if ok: if result: # has message update_last_seen_ts(args.session_id, result["ts"]) _out(result) # None → no message @@ -505,12 +512,12 @@ async def _cmd_chat(args: argparse.Namespace) -> None: since = datetime.fromtimestamp(ts_val, tz=timezone.utc).isoformat() except ValueError: since = args.since - result = await _daemon_request("/chat/history", { + ok, result = await _daemon_request("/chat/history", { "session_id": args.session_id, "since": since, "limit": args.limit, }) - if result is not None: + if ok: _out(result) return except CekiError as e: @@ -571,8 +578,8 @@ async def on_msg(msg): async def _cmd_stop(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/stop", {"session_id": args.session_id}) - if result is not None: + ok, result = await _daemon_request("/stop", {"session_id": args.session_id}) + if ok: delete_session(args.session_id) _out({"ok": True}) return @@ -597,12 +604,12 @@ async def _cmd_profile(args: argparse.Namespace) -> None: try: if args.profile_action == "export": domains = ",".join(args.domains) if args.domains else None - result = await _daemon_request("/profile/export", { + ok, result = await _daemon_request("/profile/export", { "session_id": args.session_id, "domains": domains, "no_session_storage": args.no_session_storage, }) - if result is not None: + if ok: with open(args.output, "w") as f: json.dump(result, f) _out({"ok": True, "path": args.output}) @@ -610,11 +617,11 @@ async def _cmd_profile(args: argparse.Namespace) -> None: elif args.profile_action == "import": with open(args.input, "r") as f: profile_dict = json.load(f) - result = await _daemon_request("/profile/import", { + ok, result = await _daemon_request("/profile/import", { "session_id": args.session_id, "profile": profile_dict, }) - if result is not None: + if ok: _out({"ok": True}) return except CekiError as e: @@ -719,11 +726,11 @@ async def _cmd_wait(args: argparse.Namespace) -> None: async def _cmd_screenshot(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/screenshot", { + ok, result = await _daemon_request("/screenshot", { "session_id": args.session_id, "full": args.full, }) - if result is not None: + if ok: data = base64.b64decode(_unwrap_screenshot_data(result)) with open(args.output, "wb") as f: f.write(data) @@ -749,8 +756,8 @@ async def _cmd_screenshot(args: argparse.Namespace) -> None: async def _cmd_switch_tab(args: argparse.Namespace) -> None: # Try daemon IPC try: - result = await _daemon_request("/switch-tab", {"session_id": args.session_id}) - if result is not None: + ok, result = await _daemon_request("/switch-tab", {"session_id": args.session_id}) + if ok: _out({"ok": True}) return except CekiError as e: @@ -776,8 +783,8 @@ async def _cmd_configure(args: argparse.Namespace) -> None: params["masking_mode"] = args.masking_mode if args.fingerprint is not None: params["fingerprint"] = args.fingerprint - result = await _daemon_request("/configure", params) - if result is not None: + ok, result = await _daemon_request("/configure", params) + if ok: _out({"ok": True}) return except CekiError as e: @@ -1017,6 +1024,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: qa=args.qa, participants=extra_parts or None, tags=tags, + files=args.file or None, )) elif action == "comment": # `--label` → label (short header), `--desc` → description @@ -1047,6 +1055,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, )) elif action == "propose": tags = _parse_tags(args.tags) if getattr(args, "tags", None) else None @@ -1065,6 +1074,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, settings=settings, )) elif action == "edit": @@ -1084,6 +1094,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: amount=args.amount, currency=args.currency, benefitable=args.benefitable, + files=args.file or None, settings=settings, )) elif action == "progress": @@ -1091,6 +1102,7 @@ def _cmd_contract(args: argparse.Namespace) -> int: args.eid, status=args.status, desc=args.desc, + files=args.file or None, )) elif action == "vote": ids = [int(s) for s in str(args.ids).split(",") if s.strip()] @@ -1099,6 +1111,12 @@ def _cmd_contract(args: argparse.Namespace) -> int: elif action == "poll": items = cli.poll() _contract_dump({"count": len(items), "notifications": items}) + elif action == "upload-file": + _contract_dump(cli.upload_file( + args.path, + filename=args.filename, + mime=args.mime, + )) elif action == "watch": sec = max(6, int(args.interval or 8)) sys.stderr.write( @@ -1167,12 +1185,12 @@ async def _cmd_cdp(args: argparse.Namespace) -> None: # Try daemon IPC params = json.loads(args.params) if args.params else {} try: - result = await _daemon_request("/cdp", { + ok, result = await _daemon_request("/cdp", { "session_id": args.session_id, "method": args.method, "params": params, }) - if result is not None: + if ok: _out(result) return except CekiError as e: @@ -1436,6 +1454,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'." ), ) + p_cc.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the task " + "(uploaded to the backend first)." + ), + ) p_cco = csub.add_parser("comment", help="Post comment on event") p_cco.add_argument("eid", type=int) @@ -1450,6 +1478,16 @@ def build_parser() -> argparse.ArgumentParser: p_cco.add_argument("--currency") p_cco.add_argument("--benefitable") p_cco.add_argument("--desc") + p_cco.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the comment " + "(uploaded to the backend first)." + ), + ) p_cp = csub.add_parser("propose", help="Propose correction") p_cp.add_argument("eid", type=int) @@ -1471,6 +1509,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'. back/2796 persists onto the event." ), ) + p_cp.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach in the correction " + "(uploaded to the backend first)." + ), + ) p_edit = csub.add_parser("edit", help="Edit task (semantic sugar for propose)") p_edit.add_argument("eid", type=int) @@ -1492,6 +1540,16 @@ def build_parser() -> argparse.ArgumentParser: "'backend:Backend:#ff0000'." ), ) + p_edit.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach in the edit/correction " + "(uploaded to the backend first)." + ), + ) p_cpr = csub.add_parser( "progress", @@ -1500,12 +1558,30 @@ def build_parser() -> argparse.ArgumentParser: p_cpr.add_argument("eid", type=int) p_cpr.add_argument("--status", type=int) p_cpr.add_argument("--desc", required=True) + p_cpr.add_argument( + "--file", + action="append", + default=[], + dest="file", + help=( + "Repeatable. Local file path to attach to the progress comment " + "(uploaded to the backend first)." + ), + ) p_cv = csub.add_parser("vote", help="Vote on correction(s)") p_cv.add_argument("eid", type=int) p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs") p_cv.add_argument("--vote", required=True, help="true|false") + p_cuf = csub.add_parser( + "upload-file", + help="Upload a file, print the user_files record (id/url)", + ) + p_cuf.add_argument("path", help="Local file path to upload") + p_cuf.add_argument("--filename", help="Override filename (default: basename)") + p_cuf.add_argument("--mime", help="Override MIME type (default: guessed)") + csub.add_parser("poll", help="Single agent polling tick") p_cw = csub.add_parser("watch", help="Continuous polling") diff --git a/ceki_sdk/daemon.py b/ceki_sdk/daemon.py index 69e97d8..8b691a8 100644 --- a/ceki_sdk/daemon.py +++ b/ceki_sdk/daemon.py @@ -159,9 +159,28 @@ async def _handle_rent(self, params: dict) -> dict: profile = json.load(f) fp_data = profile.get("fingerprint") or True - client = await connect(api_key, _connect_options()) - browser = await client.rent(schedule, mode=mode, fingerprint=fp_data) - self.server.daemon_server._sessions[browser.session_id] = (client, browser) + # Reuse ONE shared Client per api_key — all sessions multiplex over a + # single WebSocket. Old per-rent clients were never closed, leaking a + # live WS per rent and confusing relay cdp_response routing. + daemon = self.server.daemon_server + client = daemon._clients.get(api_key) + created = client is None + if created: + client = await connect(api_key, _connect_options()) + client._on_session_ended = daemon._on_session_ended + daemon._clients[api_key] = client + try: + browser = await client.rent(schedule, mode=mode, fingerprint=fp_data) + except Exception: + if created: + daemon._clients.pop(api_key, None) + if not daemon._client_has_sessions(client): + try: + await client.disconnect() + except Exception: + pass + raise + daemon._sessions[browser.session_id] = browser return { "session_id": browser.session_id, "chat_topic_id": browser.chat_topic_id, @@ -233,17 +252,14 @@ async def _handle_snapshot(self, params: dict) -> dict: async def _handle_stop(self, params: dict) -> None: session_id = params.get("session_id", "") - entry = self.server.daemon_server._sessions.pop(session_id, None) - if entry is None: + browser = self.server.daemon_server._sessions.pop(session_id, None) + if browser is None: raise ValueError(f"session not found: {session_id}") - client, browser = entry try: await browser.close() finally: - try: - await client.disconnect() - except Exception: - pass + # Shared client is disconnected only when the LAST session ends. + await self.server.daemon_server._maybe_disconnect_clients() async def _handle_chat_send(self, params: dict) -> dict: browser = await self._resolve_browser(params) @@ -342,14 +358,14 @@ async def _handle_request_captcha(self, params: dict) -> dict: # ── session resolution ───────────────────────────────────────────── async def _resolve_browser(self, params: dict): - """Look up a stored (Client, Browser) pair by session_id.""" + """Look up a stored Browser by session_id.""" session_id = params.get("session_id", "") if not session_id: raise ValueError("session_id required") - entry = self.server.daemon_server._sessions.get(session_id) - if entry is None: + browser = self.server.daemon_server._sessions.get(session_id) + if browser is None: raise SessionNotFound(f"session not found: {session_id}") - return entry[1] + return browser _ENDPOINTS: dict[str, str] = { @@ -389,9 +405,13 @@ class DaemonServer: - A daemon thread runs a ``ThreadingHTTPServer`` that accepts IPC requests. - The HTTP handler calls :meth:`run_async` to schedule a coroutine on the event-loop and waits for its result — bridging sync → async boundaries. - - Sessions are stored in memory as ``{session_id: (Client, Browser)}``. + - Sessions are stored in memory as ``{session_id: Browser}``. + - Clients are shared per ``api_key`` in ``{api_key: Client}`` — all sessions + of one key multiplex over a single WebSocket, so there is never more than + one live connection per key. - ``SIGTERM`` / ``SIGINT`` triggers a graceful shutdown: all sessions are - closed, the PID file is removed, and the event-loop stops. + closed, shared clients are disconnected, the PID file is removed, and the + event-loop stops. """ def __init__(self, host: str = DAEMON_HOST, port: int | None = None) -> None: @@ -400,7 +420,8 @@ def __init__(self, host: str = DAEMON_HOST, port: int | None = None) -> None: self._httpd: ThreadingHTTPServer | None = None self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None - self._sessions: dict[str, tuple[Any, Any]] = {} + self._sessions: dict[str, Any] = {} + self._clients: dict[str, Any] = {} # ── public API ───────────────────────────────────────────────────── @@ -451,21 +472,70 @@ def start(self) -> None: async def _shutdown(self) -> None: log.info("shutting down (closing %d session(s))", len(self._sessions)) # Close all sessions - for session_id, (client, browser) in list(self._sessions.items()): + for session_id, browser in list(self._sessions.items()): try: await browser.close() except Exception as exc: log.debug("close session %s: %s", session_id, exc, exc_info=True) + self._sessions.clear() + # Disconnect all shared clients + for api_key, client in list(self._clients.items()): try: await client.disconnect() except Exception: pass - self._sessions.clear() + self._clients.clear() # Stop HTTP server (blocking call offloaded to thread pool) if self._httpd: await asyncio.to_thread(self._httpd.shutdown) self._loop.stop() + def _client_has_sessions(self, client: Any) -> bool: + """True if any live session belongs to *client* (via its Browser).""" + return any(browser._client is client for browser in self._sessions.values()) + + async def _disconnect_client(self, client: Any) -> None: + try: + await client.disconnect() + except Exception as exc: + log.debug("disconnect shared client: %s", exc, exc_info=True) + + async def _maybe_disconnect_clients(self) -> None: + """Disconnect shared clients once the last session for them is gone. + + Called from the HTTP handler (not the client's own reader task), so + awaiting ``client.disconnect()`` here is safe. + """ + if self._sessions: + return + clients = list(self._clients.items()) + self._clients.clear() + for _, client in clients: + await self._disconnect_client(client) + + async def _on_session_ended(self, session_id: str) -> None: + """Daemon-side cleanup when a rented session ends on the relay. + + Invoked by the shared :class:`Client` on ``session.ended``/``session_end`` + (see ``_client.py``). Removes the session from ``_sessions`` and, when + the last session is gone, closes the shared client's WebSocket so the + relay never accumulates orphan connections. + + This runs inside the client's own reader task, so disconnecting the + client must be deferred to a separate task — ``disconnect()`` cancels + the reader task, which would otherwise cancel this very coroutine and + skip the actual WS/P2P teardown. + """ + self._sessions.pop(session_id, None) + if self._sessions: + return + clients = list(self._clients.items()) + self._clients.clear() + for _, client in clients: + # Stop the reader loop synchronously; teardown happens in a task. + client._closed = True + asyncio.create_task(self._disconnect_client(client)) + def _cleanup(self) -> None: PID_FILE.unlink(missing_ok=True) log.info("daemon stopped") diff --git a/tests/test_cli.py b/tests/test_cli.py index dbe4f36..28b94eb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -514,3 +514,119 @@ def test_parser_provider_requires_subcommand(): parser = build_parser() with pytest.raises(SystemExit): parser.parse_args(["provider"]) + + +# ────────────────────────────────────────────────────────────────────────── +# Bug 1 regression: void commands with a null daemon result. +# A successful void command (navigate/click/type/stop/...) comes back as +# {"ok": true, "result": null}. _daemon_request now returns (True, None) for +# that — branching on `result is not None` used to fall through to the one-shot +# resume path, which disconnects and kills the rented session. +# ────────────────────────────────────────────────────────────────────────── + + +class _FakeResp: + def __init__(self, body: dict): + self._body = body + + def json(self): + return self._body + + +async def test_daemon_request_void_result_none_is_success(tmp_path: Path): + """_daemon_request returns (True, None) for a successful void command.""" + from ceki_sdk.cli import _daemon_request + + pid = tmp_path / "ceki-daemon.pid" + pid.write_text("12345") + + class _FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + return _FakeResp({"ok": True, "result": None}) + + with ( + patch("ceki_sdk.cli.PID_FILE", pid), + patch("ceki_sdk.cli.httpx.AsyncClient", _FakeAsyncClient), + ): + ok, result = await _daemon_request("/navigate", {"session_id": "s1"}) + + assert ok is True + assert result is None + + +async def test_daemon_request_absent_returns_false(tmp_path: Path): + """No PID file → (False, None) so the caller runs the one-shot fallback.""" + from ceki_sdk.cli import _daemon_request + + with patch("ceki_sdk.cli.PID_FILE", tmp_path / "missing.pid"): + ok, result = await _daemon_request("/navigate", {"session_id": "s1"}) + + assert ok is False + assert result is None + + +async def test_navigate_void_ok_null_result_does_not_resume(tmp_path: Path): + """Bug 1: navigate with {'ok': true, 'result': null} must print ok and must + NOT call _resume_browser (the one-shot fallback disconnects the client and + kills the session).""" + import argparse + + from ceki_sdk.cli import _cmd_navigate + + pid = tmp_path / "ceki-daemon.pid" + pid.write_text("12345") + + class _FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + return _FakeResp({"ok": True, "result": None}) + + with ( + patch("ceki_sdk.cli.PID_FILE", pid), + patch("ceki_sdk.cli.httpx.AsyncClient", _FakeAsyncClient), + patch("ceki_sdk.cli._resume_browser", AsyncMock()) as resume, + patch("ceki_sdk.cli._out") as out, + ): + args = argparse.Namespace(session_id="s1", url="https://example.com") + await _cmd_navigate(args) + + resume.assert_not_awaited() + out.assert_called_once_with({"ok": True}) + + +async def test_navigate_no_daemon_falls_back_to_oneshot(tmp_path: Path): + """Reverse path: daemon absent → (False, None) → one-shot fallback works.""" + import argparse + + from ceki_sdk.cli import _cmd_navigate + + client = AsyncMock() + client._ws = AsyncMock() + browser = AsyncMock() + + with ( + patch("ceki_sdk.cli.PID_FILE", tmp_path / "missing.pid"), + patch("ceki_sdk.cli._get_api_key", return_value="key"), + patch( + "ceki_sdk.cli._resume_browser", + AsyncMock(return_value=(client, browser)), + ) as resume, + patch("ceki_sdk.cli._out") as out, + ): + args = argparse.Namespace(session_id="s1", url="https://example.com") + await _cmd_navigate(args) + + resume.assert_awaited_once() + browser.navigate.assert_awaited_once() + out.assert_called_once_with({"ok": True}) diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..077aebf --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from ceki_sdk._client import Client +from ceki_sdk.daemon import DaemonHTTPHandler, DaemonServer + + +def _make_handler(daemon: DaemonServer) -> DaemonHTTPHandler: + """Construct an HTTP handler wired to *daemon* without a real socket.""" + handler = DaemonHTTPHandler.__new__(DaemonHTTPHandler) + server = Mock() + server.daemon_server = daemon + handler.server = server + return handler + + +def _make_browser(session_id: str, client: Mock) -> Mock: + browser = Mock() + browser.session_id = session_id + browser.chat_topic_id = f"topic-{session_id}" + browser.schedule_id = 1 + browser.close = AsyncMock() + browser._client = client + return browser + + +@pytest.mark.asyncio +async def test_daemon_reuses_shared_client_per_api_key(): + """Renting two sessions with the same key must use ONE shared Client.""" + daemon = DaemonServer() + handler = _make_handler(daemon) + + shared = AsyncMock() + shared.rent = AsyncMock(side_effect=[ + _make_browser("s1", shared), + _make_browser("s2", shared), + ]) + + with patch("ceki_sdk.daemon.connect", AsyncMock(return_value=shared)) as connect_mock: + r1 = await handler._handle_rent({"api_key": "key", "schedule": 5}) + r2 = await handler._handle_rent({"api_key": "key", "schedule": 6}) + + assert r1["session_id"] == "s1" + assert r2["session_id"] == "s2" + + # One shared client registered, reused across both rents — connect() once. + assert list(daemon._clients) == ["key"] + assert daemon._clients["key"] is shared + connect_mock.assert_awaited_once() + + # Both sessions registered as plain Browsers on the same client. + assert set(daemon._sessions) == {"s1", "s2"} + assert daemon._sessions["s1"]._client is shared + assert daemon._sessions["s2"]._client is shared + + # The session.ended hook is wired so the daemon learns about relay ends. + assert shared._on_session_ended.__func__ is DaemonServer._on_session_ended + assert shared._on_session_ended.__self__ is daemon + + +@pytest.mark.asyncio +async def test_daemon_rent_failure_does_not_leak_new_client(): + """A failed rent on a fresh client must clean up that client.""" + daemon = DaemonServer() + handler = _make_handler(daemon) + + shared = AsyncMock() + shared.rent = AsyncMock(side_effect=RuntimeError("no providers")) + + with patch("ceki_sdk.daemon.connect", AsyncMock(return_value=shared)): + with pytest.raises(RuntimeError): + await handler._handle_rent({"api_key": "key", "schedule": 5}) + + assert daemon._clients == {} + assert daemon._sessions == {} + shared.disconnect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_daemon_session_ended_disconnects_only_when_empty(): + """session.ended removes the sid; shared client is closed only after the + last session is gone.""" + daemon = DaemonServer() + client_a = AsyncMock() + client_b = AsyncMock() + daemon._clients["key-a"] = client_a + daemon._clients["key-b"] = client_b + b1 = _make_browser("s1", client_a) + b2 = _make_browser("s2", client_a) + daemon._sessions = {"s1": b1, "s2": b2} + + # First session ends → still one live session → no disconnect. + await daemon._on_session_ended("s1") + assert daemon._sessions == {"s2": b2} + assert daemon._clients == {"key-a": client_a, "key-b": client_b} + client_a.disconnect.assert_not_awaited() + client_b.disconnect.assert_not_awaited() + + # Last session ends → all shared clients torn down (as a scheduled task). + await daemon._on_session_ended("s2") + assert daemon._sessions == {} + assert daemon._clients == {} + assert client_a._closed is True + assert client_b._closed is True + await asyncio.sleep(0.05) # let the scheduled disconnect tasks run + client_a.disconnect.assert_awaited_once() + client_b.disconnect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_daemon_stop_disconnects_only_when_last_session(): + """/stop pops the sid and disconnects the shared client only on the last.""" + daemon = DaemonServer() + handler = _make_handler(daemon) + shared = AsyncMock() + daemon._clients["key"] = shared + b1 = _make_browser("s1", shared) + b2 = _make_browser("s2", shared) + daemon._sessions = {"s1": b1, "s2": b2} + + await handler._handle_stop({"session_id": "s1"}) + assert daemon._sessions == {"s2": b2} + b1.close.assert_awaited_once() + shared.disconnect.assert_not_awaited() + + await handler._handle_stop({"session_id": "s2"}) + assert daemon._sessions == {} + b2.close.assert_awaited_once() + shared.disconnect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_daemon_shutdown_closes_sessions_and_disconnects_clients(): + """Shutdown closes every browser and disconnects every shared client.""" + daemon = DaemonServer() + shared = AsyncMock() + daemon._clients["key"] = shared + b1 = _make_browser("s1", shared) + daemon._sessions = {"s1": b1} + daemon._httpd = None + daemon._loop = Mock() + + await daemon._shutdown() + + b1.close.assert_awaited_once() + shared.disconnect.assert_awaited_once() + assert daemon._sessions == {} + assert daemon._clients == {} + + +@pytest.mark.asyncio +async def test_client_dispatch_invokes_session_ended_hook(): + """The shared client fires _on_session_ended when the relay ends a session.""" + client = Client( + api_key="k", + relay_url="wss://relay/ws/agent", + api_url="https://api", + chat_url="https://chat", + reconnect=False, + ) + client._ws = AsyncMock() + client._ws.send = AsyncMock() + + ended: list[str] = [] + + async def hook(session_id: str) -> None: + ended.append(session_id) + + client._on_session_ended = hook + await client._dispatch({"type": "session.ended", "session_id": "s9", "reason": "completed"}) + assert ended == ["s9"] + + +@pytest.mark.asyncio +async def test_client_dispatch_session_end_alias_invokes_hook(): + """session_end (alias) also reaches the daemon hook.""" + client = Client( + api_key="k", + relay_url="wss://relay/ws/agent", + api_url="https://api", + chat_url="https://chat", + reconnect=False, + ) + client._ws = AsyncMock() + client._ws.send = AsyncMock() + + ended: list[str] = [] + + async def hook(session_id: str) -> None: + ended.append(session_id) + + client._on_session_ended = hook + await client._dispatch({"type": "session_end", "session_id": "s10"}) + assert ended == ["s10"] + + +@pytest.mark.asyncio +async def test_shared_client_keeps_exactly_one_ws(mock_relay): + """After N rents+closes on a shared client the relay sees EXACTLY 1 WS (not N), + and it drops to 0 once the last session ends. End-to-end over a real local + WebSocket: rent() multiplexes over one connection, session.ended feeds the + daemon hook, and the daemon disconnects the shared client when idle.""" + from ceki_sdk import ConnectOptions, connect + + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + daemon = DaemonServer() + + with patch.dict( + "os.environ", + {"CEKI_FORCE_WS": "1", "CEKI_HUMAN_DISABLE": "1"}, + ): + client = await connect("test-key", ConnectOptions(relay_url=url)) + client._on_session_ended = daemon._on_session_ended + + async def ack_rent(session_id: str) -> None: + await asyncio.sleep(0.05) + ev_id = f"ev-{session_id}" + await mock_relay.send_to_all( + {"type": "rent_pending", "event_id": ev_id, "schedule_id": 1} + ) + await asyncio.sleep(0.02) + await mock_relay.send_to_all({ + "type": "match", + "event_id": ev_id, + "session_id": session_id, + "schedule_id": 1, + "chat_topic_id": None, + "browser_info": {}, + }) + + t1 = asyncio.create_task(ack_rent("sess-A")) + b1 = await client.rent(1) + await t1 + t2 = asyncio.create_task(ack_rent("sess-B")) + b2 = await client.rent(2) + await t2 + + # Both sessions multiplex over the SAME shared WebSocket. + assert len(mock_relay.connections) == 1 + + # Mirror what _handle_rent does: register the shared client + sessions. + daemon._clients = {"test-key": client} + daemon._sessions = {"sess-A": b1, "sess-B": b2} + + # First session ends → still one live session → WS stays up. + await mock_relay.send_to_all( + {"type": "session.ended", "session_id": "sess-A", "reason": "completed"} + ) + await asyncio.sleep(0.15) + assert len(mock_relay.connections) == 1 + assert daemon._sessions == {"sess-B": b2} + + # Last session ends → shared client disconnects → no orphan WS. + await mock_relay.send_to_all( + {"type": "session.ended", "session_id": "sess-B", "reason": "completed"} + ) + for _ in range(50): + if len(mock_relay.connections) == 0: + break + await asyncio.sleep(0.05) + assert len(mock_relay.connections) == 0 + assert daemon._sessions == {} From 75dce475f9fcd265da5ac29fb315d738a9e40992 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Tue, 11 Aug 2026 16:17:25 +0000 Subject: [PATCH 4/5] fix(sdk): handle relay session_ended and error -1011 on provider death The relay ends a session with 'session_ended' (underscore) carrying the id in 'event_id', not 'session.ended'/'session_id' which the client listened for. Relay-initiated ends (provider death, admin stop, backend reaper) were therefore silently dropped, leaving a zombie entry in the daemon's session registry and a live WS to the relay until an explicit /stop. - dispatch: accept session.ended / session_end / session_ended, read the id from session_id or event_id - dispatch: error -1011/-1018 with a session id is a session end, route it through the same cleanup (browser teardown + daemon hook) instead of logging it as an unhandled relay error - tests: unit + WS-level E2E reproducing the provider-death sequence (session.provider_disconnected -> grace -> error -1011 + session_ended) verifying the daemon drops the session and closes the shared WS --- ceki_sdk/_client.py | 30 +++++++-- tests/test_daemon.py | 152 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 7 deletions(-) diff --git a/ceki_sdk/_client.py b/ceki_sdk/_client.py index bf08962..26dbd77 100644 --- a/ceki_sdk/_client.py +++ b/ceki_sdk/_client.py @@ -509,8 +509,14 @@ async def _dispatch(self, msg: dict[str, Any]) -> None: if browser: await browser._on_tab_opened(msg) return - if mtype in ("session.ended", "session_end"): - session_id = msg.get("session_id", "") + if mtype in ("session.ended", "session_end", "session_ended"): + # The relay's session-end message is ``session_ended`` with the id in + # ``event_id`` (older aliases used ``session_id``). Accept every form + # so relay-initiated ends (provider death, admin stop, backend reaper) + # are never dropped — otherwise the daemon would keep the session and + # its shared WS alive forever. + sid = msg.get("session_id") or msg.get("event_id") + session_id = str(sid) if sid else "" browser = self._active_browsers.get(session_id) if browser: await browser._on_session_ended(msg) @@ -566,9 +572,23 @@ async def _dispatch(self, msg: dict[str, Any]) -> None: asyncio.create_task(browser.chat._on_send_error(msg)) return if mtype == "error": - session_id = msg.get("session_id") - if session_id and session_id in self._active_browsers: - await self._active_browsers[session_id]._on_error(msg) + sid = msg.get("session_id") or msg.get("event_id") + session_id = str(sid) if sid else "" + browser = self._active_browsers.get(session_id) if session_id else None + if browser is not None and msg.get("code", 0) in (-1011, -1018): + # Relay reports a session end as ``error -1011/-1018`` (provider + # death, grace expiry, admin kill). Clean up exactly like + # ``session_ended`` so the daemon never keeps a dead session. + await browser._on_session_ended(msg) + hook = self._on_session_ended + if hook is not None: + try: + await hook(session_id) + except Exception as exc: + log.error("session.ended hook failed: %s", exc) + return + if browser is not None: + await browser._on_error(msg) else: self._handle_error(msg) return diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 077aebf..92018b2 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -198,6 +198,77 @@ async def hook(session_id: str) -> None: assert ended == ["s10"] +@pytest.mark.asyncio +async def test_client_dispatch_relay_session_ended_event_id_invokes_hook(): + """The relay's real ``session_ended`` (underscore, id in ``event_id``) must + reach the daemon hook — otherwise relay-initiated ends (provider death, + admin stop, backend reaper) leak the session and its shared WS.""" + client = Client( + api_key="k", + relay_url="wss://relay/ws/agent", + api_url="https://api", + chat_url="https://chat", + reconnect=False, + ) + client._ws = AsyncMock() + client._ws.send = AsyncMock() + + browser = Mock() + browser.session_id = "s11" + browser._on_session_ended = AsyncMock() + client._active_browsers["s11"] = browser + + ended: list[str] = [] + + async def hook(session_id: str) -> None: + ended.append(session_id) + + client._on_session_ended = hook + await client._dispatch({ + "type": "session_ended", + "event_id": "s11", + "reason": "provider_disconnected", + }) + assert ended == ["s11"] + browser._on_session_ended.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_client_dispatch_error_1011_with_event_id_cleans_session(): + """error -1011 (provider death, grace expiry) is a session end: it must + clean the browser AND notify the daemon hook, not just log an unhandled + relay error (which previously left the session/WS alive forever).""" + client = Client( + api_key="k", + relay_url="wss://relay/ws/agent", + api_url="https://api", + chat_url="https://chat", + reconnect=False, + ) + client._ws = AsyncMock() + client._ws.send = AsyncMock() + + browser = Mock() + browser.session_id = "s12" + browser._on_session_ended = AsyncMock() + client._active_browsers["s12"] = browser + + ended: list[str] = [] + + async def hook(session_id: str) -> None: + ended.append(session_id) + + client._on_session_ended = hook + await client._dispatch({ + "type": "error", + "code": -1011, + "event_id": "s12", + "reason": "provider_disconnected", + }) + assert ended == ["s12"] + browser._on_session_ended.assert_awaited_once() + + @pytest.mark.asyncio async def test_shared_client_keeps_exactly_one_ws(mock_relay): """After N rents+closes on a shared client the relay sees EXACTLY 1 WS (not N), @@ -254,9 +325,11 @@ async def ack_rent(session_id: str) -> None: assert len(mock_relay.connections) == 1 assert daemon._sessions == {"sess-B": b2} - # Last session ends → shared client disconnects → no orphan WS. + # Last session ends via the relay's REAL format (``session_ended`` + + # ``event_id``, as sent by finishSession on provider death / admin stop) + # → shared client disconnects → no orphan WS. await mock_relay.send_to_all( - {"type": "session.ended", "session_id": "sess-B", "reason": "completed"} + {"type": "session_ended", "event_id": "sess-B", "reason": "completed"} ) for _ in range(50): if len(mock_relay.connections) == 0: @@ -264,3 +337,78 @@ async def ack_rent(session_id: str) -> None: await asyncio.sleep(0.05) assert len(mock_relay.connections) == 0 assert daemon._sessions == {} + + +@pytest.mark.asyncio +async def test_provider_death_cleans_session_and_ws(mock_relay): + """QA repro: on provider death the relay sends ``session.provider_disconnected``, + then after grace ``error -1011`` + ``session_ended`` (id in ``event_id``). + The daemon must drop the dead session and close the shared WS — previously + ``session_ended`` was silently ignored, leaving a live WS and a zombie entry.""" + from ceki_sdk import ConnectOptions, connect + + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + daemon = DaemonServer() + + with patch.dict( + "os.environ", + {"CEKI_FORCE_WS": "1", "CEKI_HUMAN_DISABLE": "1"}, + ): + client = await connect("test-key", ConnectOptions(relay_url=url)) + client._on_session_ended = daemon._on_session_ended + + async def ack_rent(session_id: str) -> None: + await asyncio.sleep(0.05) + ev_id = f"ev-{session_id}" + await mock_relay.send_to_all( + {"type": "rent_pending", "event_id": ev_id, "schedule_id": 1} + ) + await asyncio.sleep(0.02) + await mock_relay.send_to_all({ + "type": "match", + "event_id": ev_id, + "session_id": session_id, + "schedule_id": 1, + "chat_topic_id": None, + "browser_info": {}, + }) + + t = asyncio.create_task(ack_rent("sess-C")) + browser = await client.rent(1) + await t + + assert len(mock_relay.connections) == 1 + daemon._clients = {"test-key": client} + daemon._sessions = {"sess-C": browser} + + # 1) Provider goes down → grace starts. Session stays tracked (provider + # may rejoin), the WS stays up. + await mock_relay.send_to_all({ + "type": "session.provider_disconnected", + "session_id": "sess-C", + "retry_within_ms": 60000, + }) + await asyncio.sleep(0.1) + assert daemon._sessions == {"sess-C": browser} + assert len(mock_relay.connections) == 1 + + # 2) Grace expires → relay reports the end (exact finishSession payloads). + await mock_relay.send_to_all({ + "type": "error", + "code": -1011, + "event_id": "sess-C", + "reason": "provider_disconnected", + }) + await mock_relay.send_to_all({ + "type": "session_ended", + "event_id": "sess-C", + "reason": "provider_disconnected", + }) + + # Session dropped + shared client disconnected → no orphan WS. + for _ in range(50): + if not daemon._sessions and len(mock_relay.connections) == 0: + break + await asyncio.sleep(0.05) + assert daemon._sessions == {} + assert len(mock_relay.connections) == 0 From 21642e8afba90b977dc5932f6509f8252e1d6354 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Wed, 12 Aug 2026 11:25:00 +0000 Subject: [PATCH 5/5] refactor: move docker image to Ceki-me/docker-browser --- docker/Dockerfile | 55 -------------------------------- docker/README.md | 67 --------------------------------------- docker/build.sh | 49 ---------------------------- docker/docker-compose.yml | 24 -------------- docker/entrypoint.sh | 32 ------------------- pyproject.toml | 2 +- 6 files changed, 1 insertion(+), 228 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 docker/README.md delete mode 100755 docker/build.sh delete mode 100644 docker/docker-compose.yml delete mode 100644 docker/entrypoint.sh diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index bec4fbf..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# syntax=docker/dockerfile:1 -# -# Ceki headless-browser provider image. -# -# Thin wrapper around the python-sdk "provider" mode: -# * Chromium (Playwright) — the rented public browser -# * the ceki browser extension (dist) — the provider agent inside the browser -# * `ceki provider run` — launches Chromium + extension, injects the token and -# keeps the browser online until a renter connects or the process stops -# -# The browser token is passed at runtime via CEKI_PROVIDER_TOKEN. -# -# Build context = python-sdk repo root. `docker/build.sh` stages the extension -# dist into docker/extension/ before `docker build`. - -FROM python:3.11-slim AS runtime - -ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ - DISPLAY=:99 - -WORKDIR /opt/ceki - -# Chromium runtime libraries + Xvfb virtual display (Chromium needs a display to -# run as a "visible" provider browser; Xvfb provides it headlessly). -RUN apt-get update && apt-get install -y --no-install-recommends \ - libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ - libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \ - libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 \ - libglib2.0-0 libgdk-pixbuf-2.0-0 xvfb xauth x11-utils ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - -# Python SDK (provider extra pulls in playwright). -COPY pyproject.toml README.md ./ -COPY ceki_sdk/ ./ceki_sdk/ -RUN pip install --no-cache-dir '.[provider]' - -# Chromium pinned by Playwright (used only by the provider browser). -RUN python -m playwright install chromium - -# Bundled browser extension dist (staged into docker/extension by build.sh). -COPY docker/extension/ /opt/ceki/extension/ - -# CEKI_PROVIDER_TOKEN is supplied at runtime (docker run -e ...). -ENV CEKI_PROVIDER_EXT_DIR=/opt/ceki/extension \ - CEKI_PROVIDER_LOG_LEVEL=INFO - -# Entrypoint starts Xvfb (if needed) and then execs the command as PID 1 so a -# `docker stop` (SIGTERM to PID 1) reaches the provider for a clean shutdown. -COPY docker/entrypoint.sh /usr/local/bin/ceki-entrypoint -RUN chmod +x /usr/local/bin/ceki-entrypoint - -ENTRYPOINT ["/usr/local/bin/ceki-entrypoint"] -CMD ["ceki", "provider", "run"] diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index ff8a5e4..0000000 --- a/docker/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Headless browser provider (Docker) - -Run a ceki **provider** browser from Docker: the container starts Chromium with -the ceki extension installed, injects your browser token and keeps the browser -online so it can be rented out as a public browser. - -This is a thin wrapper around the SDK's provider mode (`ceki provider run`, -see `ceki_sdk/_provider.py`). - -## Prerequisites - -- Docker -- A **provider token** — create it in your account dashboard ("call a browser" - flow). One token = one browser = one container. - -## Build - -The image bundles the browser-extension dist, so the build script stages it -from a local clone of the extension repo before running `docker build`: - -```bash -./docker/build.sh # finds browser-extension/dist automatically -./docker/build.sh /path/to/dist # ...or point at the dist explicitly -``` - -## Run - -```bash -docker run --rm \ - -e CEKI_PROVIDER_TOKEN= \ - ceki/provider:dev -``` - -The container starts an Xvfb virtual display, launches Chromium with the -extension, brings the browser **online** and keeps it there until a renter -connects or the container is stopped. - -### docker compose - -```bash -export CEKI_PROVIDER_TOKEN= -docker compose -f docker/docker-compose.yml up -d --build -docker compose -f docker/docker-compose.yml logs -f provider -docker compose -f docker/docker-compose.yml stop provider -``` - -## Configuration - -| Env var | Default | Description | -|---|---|---| -| `CEKI_PROVIDER_TOKEN` | — | **Required.** One-time browser token from your dashboard. | -| `CEKI_API_URL` | `https://api.ceki.me` | API base URL. | -| `CEKI_PROVIDER_EXT_DIR` | `/opt/ceki/extension` | Extension dist inside the image. | -| `DISPLAY` | `:99` | X display for the virtual screen. | - -## Stopping / cleanup - -`docker stop` sends SIGTERM which the provider handles gracefully: the rented -browser is closed and the browser goes offline. `docker compose stop` does the -same. - -## Notes - -- One browser per container. To run several providers, start several containers, - each with its own token. -- The token is bound to the specific browser it was issued for; it cannot be - reused for another browser. diff --git a/docker/build.sh b/docker/build.sh deleted file mode 100755 index e9d0faf..0000000 --- a/docker/build.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -# Build the ceki headless-browser provider image. -# -# Run from the python-sdk repo root (or pass the repo root as $1): -# ./docker/build.sh -# -# What it does: -# 1. Stages the browser-extension dist into docker/extension/ -# (docker/extension/ is git-ignored; the dist never lands in git). -# 2. Runs `docker build` with the python-sdk repo as context. -# -# The extension dist is taken from the sibling clone of browser-extension: -# $CEKI_EXT_DIST (default: ../../browser-extension/dist) -# -# Optionally: ./docker/build.sh /path/to/browser-extension/dist - -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="${CEKI_IMAGE:-ceki/provider:dev}" -EXT_SRC="${1:-${CEKI_EXT_DIST:-}}" - -if [ -z "${EXT_SRC:-}" ]; then - # Try a couple of conventional locations for the extension clone. - for cand in "$ROOT/../browser-extension/dist" "$HOME/browser-extension/dist"; do - if [ -f "$cand/manifest.json" ]; then - EXT_SRC="$cand" - break - fi - done -fi - -if [ -z "${EXT_SRC:-}" ] || [ ! -f "$EXT_SRC/manifest.json" ]; then - echo "error: extension dist not found." >&2 - echo " pass the dist dir explicitly: $0 /path/to/browser-extension/dist" >&2 - echo " or set CEKI_EXT_DIST." >&2 - exit 1 -fi - -echo "[ceki-provider] staging extension dist: $EXT_SRC -> docker/extension/" -rm -rf "$ROOT/docker/extension" -mkdir -p "$ROOT/docker/extension" -cp -a "$EXT_SRC"/. "$ROOT/docker/extension/" - -echo "[ceki-provider] building image: $IMAGE" -docker build -t "$IMAGE" -f "$ROOT/docker/Dockerfile" "$ROOT" - -echo "[ceki-provider] done: $IMAGE" -echo " run: docker run --rm -e CEKI_PROVIDER_TOKEN= $IMAGE" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index c034b81..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Docker-compose reference for the ceki headless-browser provider. -# Token must be provided at runtime (never commit it here). -# -# docker compose -f docker/docker-compose.yml up --build -d -# docker compose -f docker/docker-compose.yml logs -f provider -# docker compose -f docker/docker-compose.yml stop provider - -services: - provider: - build: - context: .. - dockerfile: docker/Dockerfile - image: ceki/provider:dev - environment: - # REQUIRED: the one-time browser token from your account dashboard. - - CEKI_PROVIDER_TOKEN=${CEKI_PROVIDER_TOKEN:?set CEKI_PROVIDER_TOKEN} - # Optional: override the API base URL (defaults to https://api.ceki.me). - - CEKI_API_URL=${CEKI_API_URL:-} - # Inherit the host timezone so the provider browser's local time matches - # the IP geolocation (ev-5421 self-fingerprint consistency). - - TZ=${TZ:-} - - DISPLAY=:99 - # One provider browser per container. Scale to run more (each needs its own token). - restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index cb66440..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# Ceki headless-provider container entrypoint. -# -# Starts a virtual display (Xvfb) if none is already running, then execs the -# real command. `exec` is important: the provider process replaces this script -# and becomes PID 1, so `docker stop` (SIGTERM to PID 1) reaches the provider -# directly and the browser session is shut down cleanly (rented browser goes -# offline, no orphaned processes). -# -# DISPLAY can be overridden by the user (e.g. to attach to an external X -# server / x11vnc). - -set -e - -# Inherit a non-UTC timezone so the provider browser matches the IP geolocation -# (ev-5421). Priority: TZ env (compose passes ${TZ:-}) → /etc/timezone. -if [ -z "${TZ:-}" ] && [ -f /etc/timezone ]; then - TZ="$(cat /etc/timezone)" - export TZ -fi - -if [ -z "${DISPLAY:-}" ]; then - export DISPLAY=:99 -fi - -# Start Xvfb if it is not already up on our display. -if ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then - echo "[ceki-provider] starting Xvfb on ${DISPLAY}" - Xvfb "${DISPLAY}" -screen 0 1280x720x24 -nolisten tcp >/tmp/xvfb.log 2>&1 & -fi - -exec "$@" diff --git a/pyproject.toml b/pyproject.toml index 9009397..aede12b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5", "mypy>=1.10"] -# Browser provider mode (ceki provider run / docker image). Playwright drives +# Browser provider mode (ceki provider run). Playwright drives # Chromium; it is intentionally an extra so the base SDK stays dependency-light. provider = ["playwright>=1.40"]