Skip to content
Open
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
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<one-time browser token from your dashboard>
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=<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
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.1"
Expand All @@ -38,6 +39,8 @@
"AuthFailed",
"ConnectionLost",
"ProviderDisconnected",
"run_provider",
"ProviderError",
"SessionNotFound",
"SessionExpired",
"NotOwner",
Expand Down
44 changes: 39 additions & 5 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 @@ -503,11 +509,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 +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
Expand Down
Loading
Loading