Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ graphify-out/
.claude/
.graphifyignore
.mypy_cache/

# local leftover: old provider build dir (image moved to Ceki-me/docker-browser)
docker/
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,41 @@ 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 provider itself is **not** reimplemented in this SDK. It lives in the
public repo [Ceki-me/docker-browser](https://github.com/Ceki-me/docker-browser)
and ships as the Docker Hub image `ceki/provider`. `ceki provider run` pulls
and runs that image — the launcher stays a single source of truth.

```bash
export CEKI_PROVIDER_TOKEN=<one-time browser token from your dashboard>

ceki provider run # pull + run, stays online until stopped
ceki provider run --timeout 600 # stop after 10 minutes
ceki provider run --build ~/docker-browser # build from a local checkout instead of pulling
```

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_IMAGE` | no | Image tag (default `ceki/provider:latest`) |
| `CEKI_PROVIDER_VIEWPORT` | no | Browser viewport / resolution WxH (default `1920x1080`) |
| `CEKI_PROVIDER_LOG_LEVEL` | no | Container log verbosity (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `TZ` | no | Browser timezone (kept consistent with your location) |
| `DISPLAY` | no | X display (the container starts its own virtual screen if unset) |

`docker stop` (or Ctrl-C) sends a clean shutdown signal: the rented browser is
closed and your browser goes **offline**.

### `ceki contract` — participate in contracts via `/mcp/agent`

For AI agents executing tasks inside a contract: list contracts/jobs, post
Expand Down
3 changes: 3 additions & 0 deletions ceki_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.2"
Expand All @@ -38,6 +39,8 @@
"AuthFailed",
"ConnectionLost",
"ProviderDisconnected",
"run_provider",
"ProviderError",
"SessionNotFound",
"SessionExpired",
"NotOwner",
Expand Down
60 changes: 53 additions & 7 deletions ceki_sdk/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any

import httpx
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -210,6 +216,12 @@ async def rent(
self._pending_rent_queue.remove(fut)
except ValueError:
pass
# ``rent_pending`` already moved *fut* into ``_pending_rents`` keyed
# by the relay's event_id — drop it there too, or a long-running
# client (the daemon) accumulates a dead future per timed-out rent.
for eid, pfut in list(self._pending_rents.items()):
if pfut is fut:
del self._pending_rents[eid]
raise TimeoutError("rent timed out waiting for match")

# Wait for P2P WebRTC transport to initialize before returning Browser.
Expand Down Expand Up @@ -486,8 +498,14 @@ async def _dispatch(self, msg: dict[str, Any]) -> None:
if mtype == "cdp_response":
session_id = msg.get("session_id", "")
browser = self._active_browsers.get(session_id)
log.debug("WS cdp_response: sid=%s browser=%s active=%s msg_id=%s ok=%s",
session_id, bool(browser), list(self._active_browsers.keys()), msg.get("id"), msg.get("ok"))
log.debug(
"WS cdp_response: sid=%s browser=%s active=%s msg_id=%s ok=%s",
session_id,
bool(browser),
list(self._active_browsers.keys()),
msg.get("id"),
msg.get("ok"),
)
if browser:
await browser._on_cdp_response(msg)
return
Expand All @@ -503,11 +521,25 @@ 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)
# 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", "")
Expand Down Expand Up @@ -552,9 +584,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
Expand Down
188 changes: 188 additions & 0 deletions ceki_sdk/_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Provider mode: rent out this machine's browser through Ceki.

A thin wrapper around the official provider image. The provider itself — real
Chromium + the Ceki extension + token handshake + online poll + liveness —
lives in the public repo ``Ceki-me/docker-browser``. The SDK does NOT
reimplement the provider; it pulls an image and runs it, so the launcher stays a
single source of truth in docker-browser. The image is pulled from Docker Hub
(``ceki/provider:latest``) first and, if that pull fails, from the GHCR build
(``ghcr.io/ceki-me/docker-browser:latest``) that docker-browser CI publishes on
every GitHub Release.

CLI entry:
ceki provider run [--token TOKEN] [--image IMAGE] [--build DIR]
[--viewport WxH] [--timeout SECONDS] [--verbose]

Environment variables:
CEKI_PROVIDER_TOKEN extension token for this browser (required)
CEKI_PROVIDER_IMAGE image tag (default ``ceki/provider:latest``)
CEKI_PROVIDER_VIEWPORT browser viewport WxH (default 1920x1080)
CEKI_PROVIDER_LOG_LEVEL container log verbosity (default INFO)
TZ timezone passed into the container
DISPLAY X display (the container starts Xvfb if unset)

Only the public provider envs are passed through — internal envs
(CEKI_WS_URL / CEKI_API_URL / update knobs) are not part of the SDK contract.
"""

from __future__ import annotations

import os
import shutil
import subprocess

DEFAULT_IMAGE = "ceki/provider:latest"

# GHCR copy of the same launcher, published by docker-browser CI on every
# GitHub Release. Used when the Docker Hub tag is not (yet) available.
FALLBACK_IMAGE = "ghcr.io/ceki-me/docker-browser:latest"

# Public docker-browser envs forwarded from the caller's environment.
_PUBLIC_ENVS = ("CEKI_PROVIDER_VIEWPORT", "CEKI_PROVIDER_LOG_LEVEL", "TZ", "DISPLAY")

# Default command the image runs. ``docker run`` args override the image CMD,
# so ``--timeout`` is passed by appending this command + the flag.
_APP_CMD = ("python", "-m", "ceki_browser_provider.app")


class ProviderError(Exception):
"""Raised when the provider cannot be deployed or brought online."""


def resolve_token(token: str | None = None) -> str:
"""Resolve the provider token from arg or environment."""
value = (token or os.environ.get("CEKI_PROVIDER_TOKEN") or "").strip()
if not value:
raise ProviderError(
"Provider token is required: set CEKI_PROVIDER_TOKEN or pass --token"
)
return value


def resolve_image(explicit: str | None = None) -> str:
"""Resolve the image tag from arg, env or the default."""
return (explicit or os.environ.get("CEKI_PROVIDER_IMAGE") or DEFAULT_IMAGE).strip()


def _docker() -> str:
binary = shutil.which("docker")
if binary is None:
raise ProviderError(
"Docker is required to run a provider. Install Docker, or build the "
"provider manually from https://github.com/Ceki-me/docker-browser"
)
return binary


def _env_map(token: str, viewport: str | None = None, verbose: bool = False) -> dict[str, str]:
"""Build the container env: token + public env pass-through + explicit args."""
env = {"CEKI_PROVIDER_TOKEN": token}
for name in _PUBLIC_ENVS:
if os.environ.get(name):
env[name] = os.environ[name]
if viewport:
env["CEKI_PROVIDER_VIEWPORT"] = viewport
if verbose:
env["CEKI_PROVIDER_LOG_LEVEL"] = "DEBUG"
return env


def _run_cmd(
docker: str,
image: str,
env: dict[str, str],
timeout: int | None = None,
) -> list[str]:
"""Build the ``docker run`` command line (token never logged)."""
cmd = [docker, "run", "--rm"]
for name, value in env.items():
cmd += ["-e", f"{name}={value}"]
cmd.append(image)
if timeout:
# docker run args replace the image CMD — keep the image's default
# entry command and append --timeout so the container self-stops.
cmd.extend([*_APP_CMD, f"--timeout={timeout}"])
return cmd


def _build_image(build_dir: str) -> None:
"""Build ``ceki/provider:latest`` from a local docker-browser checkout."""
build_sh = os.path.join(build_dir, "build.sh")
if not os.path.isfile(build_sh):
raise ProviderError(
f"{build_sh} not found — pass the docker-browser repo directory "
"(https://github.com/Ceki-me/docker-browser)"
)
print(f"[ceki-provider] building image from {build_dir} (./build.sh) ...")
if subprocess.call([build_sh], cwd=build_dir) != 0:
raise ProviderError("docker-browser build.sh failed")


def _pull_image(docker: str, image: str) -> bool:
"""Pull ``image`` if not present locally; True if it is runnable now."""
if (
subprocess.run([docker, "image", "inspect", image], capture_output=True)
.returncode
== 0
):
return True
print(f"[ceki-provider] pulling {image} ...")
return subprocess.run([docker, "pull", image]).returncode == 0


def _pull_or_fallback(docker: str, image: str) -> str:
"""Return the first image tag that is runnable, trying the GHCR fallback.

An explicit tag (``--image`` / ``$CEKI_PROVIDER_IMAGE``) is used as-is and
never swapped; only the default Docker Hub tag gets the GHCR fallback,
since the Hub image may not be published yet.
"""
candidates = [image]
if image == DEFAULT_IMAGE:
candidates.append(FALLBACK_IMAGE)
for candidate in candidates:
if _pull_image(docker, candidate):
return candidate
raise ProviderError(
"failed to pull "
+ " and ".join(f"'{candidate}'" for candidate in candidates)
+ " — check the image name and network access"
)


def run_provider(
*,
token: str | None = None,
image: str | None = None,
build: str | None = None,
viewport: str | None = None,
timeout: int | None = None,
verbose: bool = False,
) -> int:
"""Pull and run the docker-browser provider image until stopped.

Returns a process exit code (0 on clean shutdown, 130 on Ctrl-C).
"""
token_value = resolve_token(token)
image_value = resolve_image(image)
docker = _docker()

if build:
_build_image(build)

# Resolve a runnable image: the Docker Hub tag first, the GHCR copy as a
# fallback when the Hub tag is not published (or not reachable) yet.
run_image = _pull_or_fallback(docker, image_value)

env = _env_map(token_value, viewport=viewport, verbose=verbose)
cmd = _run_cmd(docker, run_image, env, timeout=timeout)

print(
f"[ceki-provider] starting {run_image} — browser online until stopped "
"(Ctrl-C / docker stop)"
)
try:
code = subprocess.call(cmd)
except KeyboardInterrupt:
return 130
return 0 if code in (0, 130) else code
Loading
Loading