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
1 change: 1 addition & 0 deletions backend/druks/browser/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class BrowserSessionStatus(StrEnum):
NEEDS_LOGIN = "needs_login"
READY = "ready"
STALE = "stale"
ANONYMOUS = "anonymous"


class BrowserSessionPayloadFormat(StrEnum):
Expand Down
10 changes: 10 additions & 0 deletions backend/druks/browser/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ def __init__(self, name: str) -> None:
super().__init__(f"Browser session {name!r} does not exist.")


class BrowserSessionAnonymousError(BrowserApiError):
status_code = 409

def __init__(self, name: str) -> None:
super().__init__(
f"Browser session {name!r} is anonymous; it needs no login and "
"stores no state. Borrow it directly."
)


class BrowserSessionNotReadyError(Exception):
def __init__(self, name: str, status: str) -> None:
super().__init__(f"Browser session {name!r} is {status}; log in before borrowing it.")
Expand Down
25 changes: 2 additions & 23 deletions backend/druks/browser/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
)
from druks.browser.enums import BrowserSessionPayloadFormat
from druks.browser.models import StoredBrowserSession
from druks.browser.sessions import SESSION_ROOT
from druks.browser.sessions import SESSION_ROOT, seed_state
from druks.redis import get_client
from druks.sandbox.client import sandbox_client
from druks.sandbox.host import Sandbox
Expand Down Expand Up @@ -51,7 +51,7 @@ async def open(cls, session: StoredBrowserSession) -> "LoginWindow":
except Exception as error:
raise exceptions.BrowserLaunchError(session.name, str(error)) from error
try:
await _seed(browser, session)
await seed_state(browser, session)
await _launch(
browser,
session.name,
Expand Down Expand Up @@ -153,27 +153,6 @@ async def _carry_screen(websocket: WebSocket, screen_reader: asyncssh.SSHReader[
await websocket.send_bytes(pixels)


async def _seed(browser: Sandbox, session: StoredBrowserSession) -> None:
with tempfile.TemporaryDirectory(prefix="druks-browser-") as staging:
if session.payload:
filename = (
"state.json"
if session.payload_format == BrowserSessionPayloadFormat.STORAGE_STATE.value
else "state.tar.gz"
)
state = Path(staging) / filename
state.write_bytes(session.payload.decrypt())
await browser.upload_file(local=state, remote=f"{SESSION_ROOT}/{filename}")
metadata = {"format": session.payload_format, "version": 1}
else:
# A session no one has logged into yet has nothing to unpack; version
# zero tells the launcher to open a blank profile.
metadata = {"format": BrowserSessionPayloadFormat.PROFILE_DIR.value, "version": 0}
meta = Path(staging) / "state.meta.json"
meta.write_text(json.dumps(metadata))
await browser.upload_file(local=meta, remote=f"{SESSION_ROOT}/state.meta.json")


async def _launch(
browser: Sandbox, name: str, *, start_url: str, login_proxy: str, login_tz: str
) -> None:
Expand Down
5 changes: 3 additions & 2 deletions backend/druks/browser/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class StoredBrowserSession(Base, Uuid7Pk):
__tablename__ = "browser_sessions"
__table_args__ = (
CheckConstraint(
"status IN ('needs_login', 'ready', 'stale')",
"status IN ('needs_login', 'ready', 'stale', 'anonymous')",
name="browser_sessions_status_check",
),
CheckConstraint(
Expand All @@ -44,6 +44,7 @@ def get_or_create(
name: str,
payload_format: BrowserSessionPayloadFormat,
site: str,
status: BrowserSessionStatus = BrowserSessionStatus.NEEDS_LOGIN,
):
"""Concurrency-safe lookup-or-create: two first actions racing on the
same session both INSERT with ON CONFLICT DO NOTHING, then converge on
Expand All @@ -54,7 +55,7 @@ def get_or_create(
session = db_session()
session.execute(
insert(cls)
.values(name=name, payload_format=payload_format.value, site=site)
.values(name=name, payload_format=payload_format.value, site=site, status=status.value)
.on_conflict_do_nothing(index_elements=["name"])
)
return session.scalars(select(cls).where(cls.name == name)).one()
Expand Down
8 changes: 6 additions & 2 deletions backend/druks/browser/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from druks.apps.registry import browser_sessions
from druks.browser import exceptions
from druks.browser.constants import MAX_PAYLOAD_BYTES, PAYLOAD_WARNING_BYTES
from druks.browser.enums import BrowserSessionPayloadFormat, BrowserSessionStatus
from druks.browser.enums import BrowserSessionPayloadFormat
from druks.browser.login import LoginWindow, is_same_origin
from druks.browser.models import StoredBrowserSession
from druks.browser.schemas import BrowserSessionResponse
Expand All @@ -36,7 +36,7 @@ async def list_browser_sessions(account: Account = Depends(current_account)):
"name": declaration.name,
"site": declaration.site,
"is_declared": True,
"status": BrowserSessionStatus.NEEDS_LOGIN,
"status": declaration.initial_status,
}
)
else:
Expand Down Expand Up @@ -64,6 +64,8 @@ async def upload_state(
account: Account = Depends(current_session_account),
) -> None:
if declaration := browser_sessions.get(name):
if declaration.anonymous:
raise exceptions.BrowserSessionAnonymousError(name)
row = declaration.get_or_create_row()
payload = bytearray()
async for chunk in request.stream():
Expand All @@ -87,6 +89,8 @@ async def open_login_window(
account: Account = Depends(current_session_account),
) -> None:
if declaration := browser_sessions.get(name):
if declaration.anonymous:
raise exceptions.BrowserSessionAnonymousError(name)
await LoginWindow.open(declaration.get_or_create_row())
return
raise exceptions.BrowserSessionUnknownError(name)
Expand Down
70 changes: 49 additions & 21 deletions backend/druks/browser/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,27 +46,46 @@ class BrowserSession:
persist: bool = False
# Opt-in optimization for sites that don't fingerprint headless chromium.
headless: bool = False
# The session needs no login: every borrow opens a blank profile and the
# operator is never asked to sign in.
anonymous: bool = False
name: str = field(init=False, default="")

def __post_init__(self) -> None:
if self.anonymous and self.persist:
raise ValueError(
"BrowserSession(anonymous=True, persist=True): an anonymous "
"session has no state to write back. Drop persist=True."
)

def __set_name__(self, owner: type, attr: str) -> None:
self.name = f"{owner.name}.{attr}"
browser_sessions.register(self)

@property
def initial_status(self) -> BrowserSessionStatus:
"""The status a session holds before anyone acts on it: anonymous
sessions never want a login."""
if self.anonymous:
return BrowserSessionStatus.ANONYMOUS
return BrowserSessionStatus.NEEDS_LOGIN

@asynccontextmanager
async def cdp(self):
"""A logged-in browser, reachable at the yielded CDP url for the
length of the block. The browser lives in its own container on the
druks box and dies with the block; a persisting session is exported
and stored back first."""
row = self._ready_row()
"""A browser carrying the session's login (blank for an anonymous
session), reachable at the yielded CDP url for the length of the
block. The browser lives in its own container on the druks box and
dies with the block; a persisting session is exported and stored
back first."""
row = self.get_or_create_row() if self.anonymous else self._ready_row()
writer_token = await acquire_writer_lock(row.id) if self.persist else ""
try:
settings = load_settings()
async with sandbox_client.ephemeral(
image_override=settings.sandbox.browser_sandbox_image,
provider=settings.sandbox.browser_sandbox_provider,
) as browser:
await self._materialize(browser, row)
await seed_state(browser, row)
await self._launch(browser)
row.mark_used()
listener = await browser.forward_local_port(CDP_PORT)
Expand Down Expand Up @@ -113,6 +132,7 @@ def get_or_create_row(self) -> StoredBrowserSession:
name=self.name,
payload_format=BrowserSessionPayloadFormat.PROFILE_DIR,
site=self.site,
status=self.initial_status,
)

def _ready_row(self) -> StoredBrowserSession:
Expand All @@ -121,21 +141,6 @@ def _ready_row(self) -> StoredBrowserSession:
raise BrowserSessionNotReadyError(self.name, row.status)
return row

async def _materialize(self, browser, row: StoredBrowserSession) -> None:
state_filename = (
"state.json"
if row.payload_format == BrowserSessionPayloadFormat.STORAGE_STATE.value
else "state.tar.gz"
)
metadata = json.dumps({"format": row.payload_format, "version": 1})
with tempfile.TemporaryDirectory(prefix="druks-browser-") as staging:
state_path = Path(staging) / state_filename
state_path.write_bytes(row.payload.decrypt())
metadata_path = Path(staging) / "state.meta.json"
metadata_path.write_text(metadata)
await browser.upload_file(local=state_path, remote=f"{SESSION_ROOT}/{state_filename}")
await browser.upload_file(local=metadata_path, remote=f"{SESSION_ROOT}/state.meta.json")

async def _launch(self, browser) -> None:
mode = "--headless" if self.headless else "--headed"
ready = await browser.exec(
Expand Down Expand Up @@ -164,3 +169,26 @@ async def _export(self, browser) -> bytes:
exported_path = Path(staging) / "state.tar.gz"
await browser.download(remote=f"{SESSION_ROOT}/out/state.tar.gz", local=exported_path)
return exported_path.read_bytes()


async def seed_state(browser, row: StoredBrowserSession) -> None:
"""Put the row's stored browser state in the container before launch."""
with tempfile.TemporaryDirectory(prefix="druks-browser-") as staging:
if row.payload:
state_filename = (
"state.json"
if row.payload_format == BrowserSessionPayloadFormat.STORAGE_STATE.value
else "state.tar.gz"
)
state_path = Path(staging) / state_filename
state_path.write_bytes(row.payload.decrypt())
await browser.upload_file(local=state_path, remote=f"{SESSION_ROOT}/{state_filename}")
metadata = {"format": row.payload_format, "version": 1}
else:
# Nothing stored — an anonymous session, or a login window opened
# before the first sign-in. Version zero tells the launcher to
# open a blank profile.
metadata = {"format": BrowserSessionPayloadFormat.PROFILE_DIR.value, "version": 0}
metadata_path = Path(staging) / "state.meta.json"
metadata_path.write_text(json.dumps(metadata))
await browser.upload_file(local=metadata_path, remote=f"{SESSION_ROOT}/state.meta.json")
7 changes: 6 additions & 1 deletion backend/druks/browser/subscribers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from druks.browser.constants import SESSION_SIGNED_OUT_SIGNAL
from druks.browser.enums import BrowserSessionStatus
from druks.browser.models import StoredBrowserSession
from druks.signals import subscribe

Expand All @@ -7,4 +8,8 @@
async def signed_out_session_goes_stale(*, session_name: str, **_: object) -> None:
# A borrow bounced and the run failed; the stored login is dead, so the
# session goes stale — the pane shows it and refuses borrows until a re-login.
StoredBrowserSession.get_for_name(session_name).mark_stale()
# An anonymous session has no login to go stale: the run still fails, the
# row stays anonymous.
row = StoredBrowserSession.get_for_name(session_name)
if row.status != BrowserSessionStatus.ANONYMOUS.value:
row.mark_stale()
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Browser sessions allow the anonymous status.

Revision ID: a4b9d3e17c62
Revises: f1d8c6a2b947
Create Date: 2026-08-24
"""

from collections.abc import Sequence

from alembic import op

revision: str = "a4b9d3e17c62"
down_revision: str | Sequence[str] | None = "f1d8c6a2b947"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.drop_constraint("browser_sessions_status_check", "browser_sessions", type_="check")
op.create_check_constraint(
"browser_sessions_status_check",
"browser_sessions",
"status IN ('needs_login', 'ready', 'stale', 'anonymous')",
)


def downgrade() -> None:
op.drop_constraint("browser_sessions_status_check", "browser_sessions", type_="check")
op.create_check_constraint(
"browser_sessions_status_check",
"browser_sessions",
"status IN ('needs_login', 'ready', 'stale')",
)
41 changes: 41 additions & 0 deletions backend/tests/test_browser_borrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from druks.browser.models import StoredBrowserSession
from druks.browser.sessions import BrowserSession
from druks.browser.subscribers import signed_out_session_goes_stale
from druks.database import db_session
from druks.sandbox.datastructures import ExecResult
from druks.secrets import utils as secret_utils
Expand All @@ -26,6 +27,7 @@ class NightWatch:
name = "night_watch"
acme = BrowserSession(site="acme.example", persist=True)
docs = BrowserSession(site="docs.example")
status_page = BrowserSession(site="status.example", anonymous=True)

return NightWatch

Expand Down Expand Up @@ -237,6 +239,45 @@ async def test_signed_out_borrow_stamps_the_session_and_stores_nothing(borrow, n
assert not redis.values # the writer lock released on the way out


async def test_anonymous_borrow_needs_no_login(borrow, night_watch):
"""An anonymous borrow works with zero operator setup: the browser opens
on a blank profile, the row records the use, and nothing is stored."""
browser, redis = borrow

async with night_watch.status_page.cdp() as cdp_url:
assert cdp_url == "http://127.0.0.1:43987"

assert list(browser.files) == ["/work/session/state.meta.json"]
assert json.loads(browser.files["/work/session/state.meta.json"]) == {
"format": "profile_dir",
"version": 0,
}
assert ["session-export"] not in browser.commands
assert not redis.values # no writer lock: nothing to serialize
row = StoredBrowserSession.get_for_name(night_watch.status_page.name)
assert row.status == BrowserSessionStatus.ANONYMOUS.value
assert row.last_used_at
assert not row.payload


def test_anonymous_with_persist_fails_at_class_definition():
with pytest.raises(ValueError, match="anonymous=True, persist=True"):
BrowserSession(site="acme.example", anonymous=True, persist=True)


async def test_signed_out_in_an_anonymous_borrow_keeps_the_row_anonymous(borrow, night_watch):
"""The run still fails under the signed-out reason, but there is no login
to go stale: the subscriber leaves the row anonymous, never wanting a login."""
with pytest.raises(BrowserSessionSignedOutError) as caught:
async with night_watch.status_page.cdp():
raise BrowserSessionSignedOutError("the target bounced the borrow")

assert caught.value.session_name == "night_watch.status_page"
await signed_out_session_goes_stale(session_name="night_watch.status_page")
row = StoredBrowserSession.get_for_name("night_watch.status_page")
assert row.status == BrowserSessionStatus.ANONYMOUS.value


async def test_playwright_yields_the_logged_in_context(borrow, night_watch, monkeypatch):
import sys
import types
Expand Down
21 changes: 21 additions & 0 deletions backend/tests/test_browser_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ def test_leftover_rows_list_as_undeclared_and_refuse_the_login_window(client, ni
assert not StoredBrowserSession.list_all()


def test_anonymous_sessions_list_as_anonymous_and_refuse_login_and_state(
client, browser_session_declarations
):
class Critic:
name = "critic"
target = BrowserSession(site="druks.local", anonymous=True)

listed = {entry["name"]: entry for entry in client.get("/api/browser-sessions").json()}
assert listed["critic.target"]["status"] == BrowserSessionStatus.ANONYMOUS

refused = client.post("/api/browser-sessions/critic.target/login-window")
assert refused.status_code == 409
assert "anonymous" in refused.json()["detail"]

uploaded = client.put(
"/api/browser-sessions/critic.target/state?payloadFormat=storage_state", content=b"x"
)
assert uploaded.status_code == 409
assert not StoredBrowserSession.list_all()


def test_opening_the_login_window_materializes_the_declared_row(client, night_watch, monkeypatch):
monkeypatch.setattr(routes, "LoginWindow", FakeLoginWindow)
monkeypatch.setattr(FakeLoginWindow, "opened", [])
Expand Down
8 changes: 7 additions & 1 deletion docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,13 @@ wrapper.

``persist=True`` writes rotated state back after each borrow — for sites that
expire an unused login. ``headless=True`` is an opt-in optimization for sites
that don't fingerprint headless browsers. When your code inside the borrow
that don't fingerprint headless browsers.

``anonymous=True`` declares a session that needs no login. A borrow opens a
browser with an empty profile and the operator is never asked to sign in. Use
it when the target is public or the app carries its own credentials — an
identity header, a token in the URL. An anonymous session stores no state, so
``persist=True`` with it fails when the class is defined. When your code inside the borrow
sees the site bounce the login, raise ``BrowserSessionSignedOutError`` (from
``druks.browser``) and druks does the rest: the session goes stale — the pane
shows it and refuses further borrows until the operator signs in again — and
Expand Down
Loading