From cb621052296e473dd02ea364816692cc8dc1fac6 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 24 Aug 2026 15:04:50 +0200 Subject: [PATCH] An anonymous BrowserSession borrows a browser with no login --- backend/druks/browser/enums.py | 1 + backend/druks/browser/exceptions.py | 10 +++ backend/druks/browser/login.py | 25 +------ backend/druks/browser/models.py | 5 +- backend/druks/browser/routes.py | 8 ++- backend/druks/browser/sessions.py | 70 +++++++++++++------ backend/druks/browser/subscribers.py | 7 +- ...e17c62_browser_sessions_allow_anonymous.py | 33 +++++++++ backend/tests/test_browser_borrow.py | 41 +++++++++++ backend/tests/test_browser_sessions.py | 21 ++++++ docs/writing-an-app.md | 8 ++- frontend/src/api/types.ts | 2 +- .../components/BrowserSessionsPane.test.tsx | 16 +++++ .../src/components/BrowserSessionsPane.tsx | 20 +++--- 14 files changed, 208 insertions(+), 59 deletions(-) create mode 100644 backend/migrations/versions/a4b9d3e17c62_browser_sessions_allow_anonymous.py diff --git a/backend/druks/browser/enums.py b/backend/druks/browser/enums.py index db2f2e04..3cae46d6 100644 --- a/backend/druks/browser/enums.py +++ b/backend/druks/browser/enums.py @@ -5,6 +5,7 @@ class BrowserSessionStatus(StrEnum): NEEDS_LOGIN = "needs_login" READY = "ready" STALE = "stale" + ANONYMOUS = "anonymous" class BrowserSessionPayloadFormat(StrEnum): diff --git a/backend/druks/browser/exceptions.py b/backend/druks/browser/exceptions.py index fdf9cad2..5c9e28fd 100644 --- a/backend/druks/browser/exceptions.py +++ b/backend/druks/browser/exceptions.py @@ -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.") diff --git a/backend/druks/browser/login.py b/backend/druks/browser/login.py index b0fd158e..32caf91e 100644 --- a/backend/druks/browser/login.py +++ b/backend/druks/browser/login.py @@ -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 @@ -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, @@ -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: diff --git a/backend/druks/browser/models.py b/backend/druks/browser/models.py index 4d14e218..b5c99c37 100644 --- a/backend/druks/browser/models.py +++ b/backend/druks/browser/models.py @@ -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( @@ -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 @@ -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() diff --git a/backend/druks/browser/routes.py b/backend/druks/browser/routes.py index dbde6d45..0028f903 100644 --- a/backend/druks/browser/routes.py +++ b/backend/druks/browser/routes.py @@ -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 @@ -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: @@ -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(): @@ -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) diff --git a/backend/druks/browser/sessions.py b/backend/druks/browser/sessions.py index 5ad108f4..8333c4dc 100644 --- a/backend/druks/browser/sessions.py +++ b/backend/druks/browser/sessions.py @@ -46,19 +46,38 @@ 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() @@ -66,7 +85,7 @@ async def cdp(self): 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) @@ -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: @@ -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( @@ -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") diff --git a/backend/druks/browser/subscribers.py b/backend/druks/browser/subscribers.py index b0904870..ad96559c 100644 --- a/backend/druks/browser/subscribers.py +++ b/backend/druks/browser/subscribers.py @@ -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 @@ -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() diff --git a/backend/migrations/versions/a4b9d3e17c62_browser_sessions_allow_anonymous.py b/backend/migrations/versions/a4b9d3e17c62_browser_sessions_allow_anonymous.py new file mode 100644 index 00000000..873c5aac --- /dev/null +++ b/backend/migrations/versions/a4b9d3e17c62_browser_sessions_allow_anonymous.py @@ -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')", + ) diff --git a/backend/tests/test_browser_borrow.py b/backend/tests/test_browser_borrow.py index 3eeee689..f1a8d47b 100644 --- a/backend/tests/test_browser_borrow.py +++ b/backend/tests/test_browser_borrow.py @@ -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 @@ -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 @@ -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 diff --git a/backend/tests/test_browser_sessions.py b/backend/tests/test_browser_sessions.py index b810d9e9..e0027b05 100644 --- a/backend/tests/test_browser_sessions.py +++ b/backend/tests/test_browser_sessions.py @@ -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", []) diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index b7c7d69e..051f4bbd 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e33296bd..190966c5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -320,7 +320,7 @@ export interface UpdateUserSettingsRequest { timezone?: string } -export type BrowserSessionStatus = 'needs_login' | 'ready' | 'stale' +export type BrowserSessionStatus = 'needs_login' | 'ready' | 'stale' | 'anonymous' export type BrowserSessionPayloadFormat = 'storage_state' | 'profile_dir' export interface BrowserSession { diff --git a/frontend/src/components/BrowserSessionsPane.test.tsx b/frontend/src/components/BrowserSessionsPane.test.tsx index beeee61f..21bba885 100644 --- a/frontend/src/components/BrowserSessionsPane.test.tsx +++ b/frontend/src/components/BrowserSessionsPane.test.tsx @@ -103,6 +103,22 @@ describe('BrowserSessionsPane', () => { expect(screen.queryByText('Delete')).toBeNull() }) + it('shows an anonymous session without any login action', async () => { + stubFetch([ + browserSession({ + name: 'critic.target', + status: 'anonymous', + payloadFormat: null, + lastUsedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }), + ]) + renderPane() + + expect(await screen.findByText('critic.target')).toBeTruthy() + expect(screen.getByText('Anonymous — no login needed')).toBeTruthy() + expect(screen.queryByRole('link')).toBeNull() + }) + it('flags an undeclared leftover row and deletes it by name', async () => { const fetchMock = stubFetch([browserSession({ isDeclared: false })]) const confirm = vi.fn(() => true) diff --git a/frontend/src/components/BrowserSessionsPane.tsx b/frontend/src/components/BrowserSessionsPane.tsx index 9d1d7774..10053641 100644 --- a/frontend/src/components/BrowserSessionsPane.tsx +++ b/frontend/src/components/BrowserSessionsPane.tsx @@ -13,6 +13,7 @@ const STATUS_LABELS: Record = { needs_login: 'Needs login', ready: 'Ready', stale: 'Stale', + anonymous: 'Anonymous — no login needed', } const FORMAT_LABELS: Record = { @@ -20,7 +21,8 @@ const FORMAT_LABELS: Record = { profile_dir: 'Profile directory', } -const LOGIN_ACTION_LABELS: Record = { +/* An anonymous session has no login action at all. */ +const LOGIN_ACTION_LABELS: Record, string> = { needs_login: 'Log in', ready: 'Open window', stale: 'Reconnect', @@ -101,13 +103,15 @@ export function BrowserSessionsPane() {
{session.isDeclared ? ( - /* A full load dismisses Settings before the login window mounts. */ - - {LOGIN_ACTION_LABELS[session.status]} - + session.status !== 'anonymous' && ( + /* A full load dismisses Settings before the login window mounts. */ + + {LOGIN_ACTION_LABELS[session.status]} + + ) ) : ( No longer declared )}