From b0a6cb161d3b679c5e78fb79b7ed77ceecfcd9e6 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 17 Aug 2026 10:44:49 +0200 Subject: [PATCH] A browser session is declared, never created by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session exists because an extension declares it; the stored row is a cache of what happened to it — login state, payload, timestamps — created by the first action that needs one: a workflow borrow, a login-window open, or a state import. The pane renders declarations joined with rows and writes nothing; a declared-but-unused session lists wanting its first login, and a row whose extension is gone lists as a leftover with delete as its only action. The wire is name-addressed — the declared name is the identity — and the list is its only read: every mutation answers 204, the per-session GET is gone, and the login window keys its Redis record by session name so the window routes never visit the database. Manual creation and rename leave the API and the UI. State uploads carry their payload format and stamp it on the row, as the borrow write-back already does. The manual login loop imports through the same lazy door, and row creation copies the ON CONFLICT DO NOTHING idiom so racing first actions converge. --- backend/druks/browser/constants.py | 1 - backend/druks/browser/exceptions.py | 4 +- backend/druks/browser/login.py | 48 ++--- backend/druks/browser/models.py | 30 ++- backend/druks/browser/routes.py | 196 +++++++----------- backend/druks/browser/schemas.py | 33 +-- backend/druks/browser/sessions.py | 23 +- backend/druks/extensions/registry.py | 1 + backend/tests/conftest.py | 13 ++ backend/tests/test_auth_boundary.py | 12 +- backend/tests/test_browser_borrow.py | 88 ++++---- .../test_browser_session_login_window.py | 20 +- backend/tests/test_browser_sessions.py | 186 +++++++++-------- frontend/src/App.tsx | 4 +- frontend/src/api/client.ts | 25 +-- frontend/src/api/types.ts | 15 +- .../components/BrowserSessionsPane.test.tsx | 100 ++++----- .../src/components/BrowserSessionsPane.tsx | 167 ++------------- .../src/components/SettingsModal.test.tsx | 4 +- frontend/src/pages/LoginWindowPage.test.tsx | 37 +--- frontend/src/pages/LoginWindowPage.tsx | 28 +-- frontend/src/styles.css | 6 +- scripts/import_browser_session.py | 50 ++--- 23 files changed, 428 insertions(+), 663 deletions(-) diff --git a/backend/druks/browser/constants.py b/backend/druks/browser/constants.py index f5c6b8ae..0dc6d1ba 100644 --- a/backend/druks/browser/constants.py +++ b/backend/druks/browser/constants.py @@ -2,7 +2,6 @@ PAYLOAD_WARNING_BYTES = 200 * 1024 * 1024 BROWSER_SESSION_NAME_MAX_LENGTH = 64 -BROWSER_SESSION_NAME_PATTERN = r"^[a-z](?:[a-z0-9-]*[a-z0-9])?$" SITE_MAX_LENGTH = 255 # TTL-only, no renewal: must outlast the longest borrow, bounded by the diff --git a/backend/druks/browser/exceptions.py b/backend/druks/browser/exceptions.py index 723df544..e2f3ded5 100644 --- a/backend/druks/browser/exceptions.py +++ b/backend/druks/browser/exceptions.py @@ -49,8 +49,8 @@ def __init__(self, name: str) -> None: class BrowserLoginWindowGoneError(BrowserApiError): status_code = 410 - def __init__(self, session_id: str) -> None: - super().__init__(f"Browser session {session_id!r} has no open login window.") + def __init__(self) -> None: + super().__init__("This login window is no longer open.") class BrowserVncError(Exception): diff --git a/backend/druks/browser/login.py b/backend/druks/browser/login.py index 39ff4ec4..cf61691d 100644 --- a/backend/druks/browser/login.py +++ b/backend/druks/browser/login.py @@ -32,15 +32,15 @@ class LoginWindow: home) between them, and frees itself on the record's TTL if the operator walks away.""" - def __init__(self, session_id: str, host_id: str) -> None: - self.session_id = session_id + def __init__(self, session_name: str, host_id: str) -> None: + self.session_name = session_name self.host_id = host_id @classmethod async def open(cls, session: StoredBrowserSession) -> "LoginWindow": - stale = await get_client().get(_key(session.id)) + stale = await get_client().get(_key(session.name)) if stale: - await cls(session.id, json.loads(stale)["host_id"])._close() + await cls(session.name, json.loads(stale)["host_id"])._close() settings = load_settings() try: browser = await sandbox_client.provision( @@ -58,20 +58,20 @@ async def open(cls, session: StoredBrowserSession) -> "LoginWindow": finally: await browser.aclose() await get_client().set( - _key(session.id), + _key(session.name), json.dumps({"host_id": browser.id}), ex=LOGIN_WINDOW_TTL_SECONDS, ) - return cls(session.id, browser.id) + return cls(session.name, browser.id) @classmethod - async def get_for_session(cls, session_id: str) -> "LoginWindow": + async def get_for_session(cls, session_name: str) -> "LoginWindow": """The window the operator has open for this session; raises once it is saved, cancelled, or aged out, which is every caller's cue to stop.""" - record = await get_client().get(_key(session_id)) + record = await get_client().get(_key(session_name)) if record: - return cls(session_id, json.loads(record)["host_id"]) - raise exceptions.BrowserLoginWindowGoneError(session_id) + return cls(session_name, json.loads(record)["host_id"]) + raise exceptions.BrowserLoginWindowGoneError async def stream(self, websocket: WebSocket) -> None: """Put the operator's canvas in front of the container's screen until @@ -98,28 +98,28 @@ async def save(self) -> StoredBrowserSession: """Store what the operator logged into as the session's payload, then tear the window down. A login always captures a profile, so a session imported as storage_state becomes a profile here.""" - session = StoredBrowserSession.get_for_id(self.session_id) - if not session: - raise exceptions.BrowserSessionUnknownError(self.session_id) - try: - async with sandbox_client.attach(host_id=self.host_id) as browser: - payload = await _export(browser, session.name) - session.payload_format = BrowserSessionPayloadFormat.PROFILE_DIR.value - session.store_payload(payload) - return session - finally: - await self._close() + session = StoredBrowserSession.get_for_name(self.session_name) + if session: + try: + async with sandbox_client.attach(host_id=self.host_id) as browser: + payload = await _export(browser, session.name) + session.payload_format = BrowserSessionPayloadFormat.PROFILE_DIR.value + session.store_payload(payload) + return session + finally: + await self._close() + raise exceptions.BrowserSessionUnknownError(self.session_name) async def cancel(self) -> None: await self._close() async def _close(self) -> None: await sandbox_client.release(host_id=self.host_id) - await get_client().delete(_key(self.session_id)) + await get_client().delete(_key(self.session_name)) -def _key(session_id: str) -> str: - return f"{LOGIN_WINDOW_KEY_PREFIX}{session_id}" +def _key(session_name: str) -> str: + return f"{LOGIN_WINDOW_KEY_PREFIX}{session_name}" def is_same_origin(websocket: WebSocket) -> bool: diff --git a/backend/druks/browser/models.py b/backend/druks/browser/models.py index 5107cf9d..4d14e218 100644 --- a/backend/druks/browser/models.py +++ b/backend/druks/browser/models.py @@ -1,6 +1,7 @@ from datetime import datetime from sqlalchemy import CheckConstraint, String, select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Mapped, mapped_column from druks.browser.constants import ( @@ -37,25 +38,26 @@ class StoredBrowserSession(Base, Uuid7Pk): last_used_at: Mapped[datetime | None] = mapped_column(default=None) @classmethod - def create( + def get_or_create( cls, *, name: str, payload_format: BrowserSessionPayloadFormat, site: str, ): - browser_session = cls( - name=name, - payload_format=payload_format.value, - site=site, + """Concurrency-safe lookup-or-create: two first actions racing on the + same session both INSERT with ON CONFLICT DO NOTHING, then converge on + the one row through the name lookup.""" + browser_session = cls.get_for_name(name) + if browser_session: + return browser_session + session = db_session() + session.execute( + insert(cls) + .values(name=name, payload_format=payload_format.value, site=site) + .on_conflict_do_nothing(index_elements=["name"]) ) - db_session().add(browser_session) - db_session().flush() - return browser_session - - @classmethod - def get_for_id(cls, session_id: str): - return db_session().get(cls, session_id) + return session.scalars(select(cls).where(cls.name == name)).one() @classmethod def list_all(cls): @@ -65,10 +67,6 @@ def list_all(cls): def get_for_name(cls, name: str): return db_session().scalar(select(cls).where(cls.name == name)) - def rename(self, name: str) -> None: - self.name = name - db_session().flush() - def mark_stale(self) -> None: self.status = BrowserSessionStatus.STALE.value db_session().flush() diff --git a/backend/druks/browser/routes.py b/backend/druks/browser/routes.py index 5daf9274..b8597d15 100644 --- a/backend/druks/browser/routes.py +++ b/backend/druks/browser/routes.py @@ -1,8 +1,7 @@ import logging from typing import Annotated -from fastapi import APIRouter, Body, Depends, HTTPException, Request, WebSocket -from sqlalchemy.exc import IntegrityError +from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket from druks.accounts.dependencies import ( current_account, @@ -11,16 +10,13 @@ ) from druks.accounts.models import Account from druks.browser import exceptions -from druks.browser.constants import ( - BROWSER_SESSION_NAME_MAX_LENGTH, - BROWSER_SESSION_NAME_PATTERN, - MAX_PAYLOAD_BYTES, - PAYLOAD_WARNING_BYTES, -) +from druks.browser.constants import MAX_PAYLOAD_BYTES, PAYLOAD_WARNING_BYTES +from druks.browser.enums import BrowserSessionPayloadFormat, BrowserSessionStatus from druks.browser.login import LoginWindow, is_same_origin from druks.browser.models import StoredBrowserSession -from druks.browser.schemas import BrowserSessionResponse, CreateBrowserSessionRequest -from druks.database import db_session, session_scope +from druks.browser.schemas import BrowserSessionResponse +from druks.database import session_scope +from druks.extensions.registry import browser_sessions logger = logging.getLogger(__name__) @@ -28,89 +24,83 @@ @router.get("", response_model=list[BrowserSessionResponse]) -async def list_browser_sessions( - account: Account = Depends(current_account), -) -> list[StoredBrowserSession]: - return StoredBrowserSession.list_all() +async def list_browser_sessions(account: Account = Depends(current_account)): + rows = {row.name: row for row in StoredBrowserSession.list_all()} + sessions = [] + for declaration in browser_sessions.all(): + try: + row = rows.pop(declaration.name) + except KeyError: + sessions.append( + { + "name": declaration.name, + "site": declaration.site, + "is_declared": True, + "status": BrowserSessionStatus.NEEDS_LOGIN, + } + ) + else: + sessions.append( + { + "name": declaration.name, + "site": declaration.site, + "is_declared": True, + "status": row.status, + "payload_format": row.payload_format, + "created_at": row.created_at, + "last_refreshed_at": row.last_refreshed_at, + "last_used_at": row.last_used_at, + } + ) + sessions.extend(rows.values()) + return sessions -@router.post("", response_model=BrowserSessionResponse) -async def create_browser_session( - body: CreateBrowserSessionRequest, - account: Account = Depends(current_session_account), -) -> StoredBrowserSession: - try: - return StoredBrowserSession.create( - name=body.name, - payload_format=body.payload_format, - site=body.site, - ) - except IntegrityError as error: - db_session().rollback() - raise HTTPException( - status_code=409, - detail=f"Browser session {body.name!r} already exists.", - ) from error - - -@router.get("/{session_id}", response_model=BrowserSessionResponse) -async def get_browser_session( - session_id: str, - account: Account = Depends(current_account), -) -> StoredBrowserSession: - browser_session = StoredBrowserSession.get_for_id(session_id) - if browser_session: - return browser_session - raise HTTPException(status_code=404, detail="Browser session not found.") - - -@router.put("/{session_id}/state", response_model=BrowserSessionResponse) +@router.put("/{name}/state", status_code=204) async def upload_state( - session_id: str, + name: str, + payload_format: Annotated[BrowserSessionPayloadFormat, Query(alias="payloadFormat")], request: Request, account: Account = Depends(current_session_account), -) -> StoredBrowserSession: - browser_session = StoredBrowserSession.get_for_id(session_id) - if not browser_session: - raise HTTPException(status_code=404, detail="Browser session not found.") - payload = bytearray() - async for chunk in request.stream(): - payload.extend(chunk) - if len(payload) > MAX_PAYLOAD_BYTES: - raise HTTPException( - status_code=413, - detail="Browser session payload exceeds the 256 MB limit.", - ) - if len(payload) >= PAYLOAD_WARNING_BYTES: - logger.warning( - "Browser session %s received a %d-byte payload.", - session_id, - len(payload), - ) - browser_session.store_payload(bytes(payload)) - return browser_session +) -> None: + if declaration := browser_sessions.get(name): + row = declaration.get_or_create_row() + payload = bytearray() + async for chunk in request.stream(): + payload.extend(chunk) + if len(payload) > MAX_PAYLOAD_BYTES: + raise HTTPException( + status_code=413, + detail="Browser session payload exceeds the 256 MB limit.", + ) + if len(payload) >= PAYLOAD_WARNING_BYTES: + logger.warning("Browser session %s received a %d-byte payload.", name, len(payload)) + row.payload_format = payload_format.value + row.store_payload(bytes(payload)) + return + raise exceptions.BrowserSessionUnknownError(name) -@router.post("/{session_id}/login-window", status_code=204) +@router.post("/{name}/login-window", status_code=204) async def open_login_window( - session_id: str, + name: str, account: Account = Depends(current_session_account), ) -> None: - session = StoredBrowserSession.get_for_id(session_id) - if not session: - raise exceptions.BrowserSessionUnknownError(session_id) - await LoginWindow.open(session) + if declaration := browser_sessions.get(name): + await LoginWindow.open(declaration.get_or_create_row()) + return + raise exceptions.BrowserSessionUnknownError(name) -@router.websocket("/{session_id}/login-window/ws") -async def login_window_socket(websocket: WebSocket, session_id: str) -> None: +@router.websocket("/{name}/login-window/ws") +async def login_window_socket(websocket: WebSocket, name: str) -> None: if not is_same_origin(websocket): await websocket.close(code=1008) return try: with session_scope(websocket.app.state.engine): await require_operator(websocket) - window = await LoginWindow.get_for_session(session_id) + window = await LoginWindow.get_for_session(name) except (HTTPException, exceptions.BrowserApiError): await websocket.close(code=1008) return @@ -120,58 +110,30 @@ async def login_window_socket(websocket: WebSocket, session_id: str) -> None: await websocket.close(code=1011) -@router.post("/{session_id}/login-window/save", response_model=BrowserSessionResponse) +@router.post("/{name}/login-window/save", status_code=204) async def save_login_window( - session_id: str, + name: str, account: Account = Depends(current_session_account), -) -> StoredBrowserSession: - window = await LoginWindow.get_for_session(session_id) - return await window.save() +) -> None: + window = await LoginWindow.get_for_session(name) + await window.save() -@router.post("/{session_id}/login-window/cancel", status_code=204) +@router.post("/{name}/login-window/cancel", status_code=204) async def cancel_login_window( - session_id: str, + name: str, account: Account = Depends(current_session_account), ) -> None: - window = await LoginWindow.get_for_session(session_id) + window = await LoginWindow.get_for_session(name) await window.cancel() -@router.patch("/{session_id}", response_model=BrowserSessionResponse) -async def rename_browser_session( - session_id: str, - name: Annotated[ - str, - Body( - embed=True, - min_length=1, - max_length=BROWSER_SESSION_NAME_MAX_LENGTH, - pattern=BROWSER_SESSION_NAME_PATTERN, - ), - ], - account: Account = Depends(current_session_account), -) -> StoredBrowserSession: - browser_session = StoredBrowserSession.get_for_id(session_id) - if not browser_session: - raise HTTPException(status_code=404, detail="Browser session not found.") - try: - browser_session.rename(name) - except IntegrityError as error: - db_session().rollback() - raise HTTPException( - status_code=409, - detail=f"Browser session {name!r} already exists.", - ) from error - return browser_session - - -@router.delete("/{session_id}", status_code=204) +@router.delete("/{name}", status_code=204) async def delete_browser_session( - session_id: str, + name: str, account: Account = Depends(current_session_account), ) -> None: - browser_session = StoredBrowserSession.get_for_id(session_id) - if not browser_session: - raise HTTPException(status_code=404, detail="Browser session not found.") - browser_session.delete() + if row := StoredBrowserSession.get_for_name(name): + row.delete() + return + raise exceptions.BrowserSessionUnknownError(name) diff --git a/backend/druks/browser/schemas.py b/backend/druks/browser/schemas.py index 11d7a232..0bbaefab 100644 --- a/backend/druks/browser/schemas.py +++ b/backend/druks/browser/schemas.py @@ -1,39 +1,20 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import ConfigDict -from druks.browser.constants import ( - BROWSER_SESSION_NAME_MAX_LENGTH, - BROWSER_SESSION_NAME_PATTERN, - SITE_MAX_LENGTH, -) from druks.browser.enums import BrowserSessionPayloadFormat, BrowserSessionStatus from druks.schemas import BaseResponse -class CreateBrowserSessionRequest(BaseModel): - model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") - - name: str = Field( - min_length=1, - max_length=BROWSER_SESSION_NAME_MAX_LENGTH, - pattern=BROWSER_SESSION_NAME_PATTERN, - ) - payload_format: BrowserSessionPayloadFormat = Field( - default=BrowserSessionPayloadFormat.STORAGE_STATE, - alias="payloadFormat", - ) - site: str = Field(min_length=1, max_length=SITE_MAX_LENGTH) - - class BrowserSessionResponse(BaseResponse): model_config = ConfigDict(from_attributes=True) - id: str name: str status: BrowserSessionStatus - payload_format: BrowserSessionPayloadFormat + payload_format: BrowserSessionPayloadFormat | None = None site: str - created_at: datetime - last_refreshed_at: datetime | None - last_used_at: datetime | None + # Only a declaration can vouch for a session; a bare row is a leftover. + is_declared: bool = False + created_at: datetime | None = None + last_refreshed_at: datetime | None = None + last_used_at: datetime | None = None diff --git a/backend/druks/browser/sessions.py b/backend/druks/browser/sessions.py index a0a08142..822cfaed 100644 --- a/backend/druks/browser/sessions.py +++ b/backend/druks/browser/sessions.py @@ -17,6 +17,7 @@ ) from druks.browser.locks import acquire_writer_lock, release_writer_lock from druks.browser.models import StoredBrowserSession +from druks.extensions.registry import browser_sessions from druks.sandbox.client import sandbox_client from druks.settings import load_settings @@ -48,6 +49,7 @@ class BrowserSession: def __set_name__(self, owner: type, attr: str) -> None: self.name = f"{owner.name}.{attr}" + browser_sessions.register(self) @asynccontextmanager async def cdp(self): @@ -99,24 +101,21 @@ async def playwright(self): def mark_stale(self) -> None: """Report the login bounced — the site wants the operator back. The pane shows the session as stale; the workflow decides whether to park.""" - self._get_or_create_row().mark_stale() - - def _get_or_create_row(self) -> StoredBrowserSession: - """The declaration's stored half, written the first time a run reaches - for the login. Until then the declaration is the only thing that exists — - the row appears in the sessions pane wanting a login, which is how the - operator learns the extension needs one.""" - row = StoredBrowserSession.get_for_name(self.name) - if row: - return row - return StoredBrowserSession.create( + self.get_or_create_row().mark_stale() + + def get_or_create_row(self) -> StoredBrowserSession: + """The declaration's stored half, written by the first action that + needs it — a borrow, a login-window open, or a state import. Until + then the declaration alone puts the session in the pane, wanting a + login.""" + return StoredBrowserSession.get_or_create( name=self.name, payload_format=BrowserSessionPayloadFormat.PROFILE_DIR, site=self.site, ) def _ready_row(self) -> StoredBrowserSession: - row = self._get_or_create_row() + row = self.get_or_create_row() if row.status != BrowserSessionStatus.READY.value: raise BrowserSessionNotReadyError(self.name, row.status) return row diff --git a/backend/druks/extensions/registry.py b/backend/druks/extensions/registry.py index e1a1a117..1489aa62 100644 --- a/backend/druks/extensions/registry.py +++ b/backend/druks/extensions/registry.py @@ -65,6 +65,7 @@ def autodiscover(package: str) -> list[ModuleType]: services = Registry("services", key=lambda cls: cls.name) workflows = Registry("workflows", key=lambda cls: cls.kind) agents = Registry("agents", key=lambda agent: agent.id) +browser_sessions = Registry("browser_sessions", key=lambda session: session.name) # MCP server definitions from the deployment's catalog, mounted by an explicit # startup load (druks/mcp/catalog.py); an operator's DB overlay enables and # tokens them. Dict items, not self-registering classes like the registries above. diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8675fb0a..b1bd611c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -90,6 +90,19 @@ def registry_state(): mcp_servers._items.update(saved) +@pytest.fixture +def browser_session_declarations(): + # BrowserSession declarations self-register at class definition, so what a + # test module defines at import time would leak into every merged-list + # read; tests declare inside this fixture and leave the registry as found. + from druks.extensions.registry import browser_sessions + + saved = dict(browser_sessions._items) + yield browser_sessions + browser_sessions._items.clear() + browser_sessions._items.update(saved) + + # These modules manage their own engine + database and commit for real — the DBOS # durable tests (their own per-test database + worker connections that read across # the commit) and the alembic migration test (its own AUTOCOMMIT engine, DDL it diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index 225f684b..455dd7b4 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -19,13 +19,11 @@ # Capability management admits the session identity only — never a PAT. SESSION_ONLY_API_ROUTES = { - ("POST", "/api/browser-sessions"), - ("PUT", "/api/browser-sessions/{session_id}/state"), - ("POST", "/api/browser-sessions/{session_id}/login-window"), - ("POST", "/api/browser-sessions/{session_id}/login-window/save"), - ("POST", "/api/browser-sessions/{session_id}/login-window/cancel"), - ("PATCH", "/api/browser-sessions/{session_id}"), - ("DELETE", "/api/browser-sessions/{session_id}"), + ("PUT", "/api/browser-sessions/{name}/state"), + ("POST", "/api/browser-sessions/{name}/login-window"), + ("POST", "/api/browser-sessions/{name}/login-window/save"), + ("POST", "/api/browser-sessions/{name}/login-window/cancel"), + ("DELETE", "/api/browser-sessions/{name}"), ("GET", "/api/auth/personal-tokens"), ("POST", "/api/auth/personal-tokens"), ("DELETE", "/api/auth/personal-tokens/{pat_id}"), diff --git a/backend/tests/test_browser_borrow.py b/backend/tests/test_browser_borrow.py index 7e312b6a..fc2160c8 100644 --- a/backend/tests/test_browser_borrow.py +++ b/backend/tests/test_browser_borrow.py @@ -19,10 +19,14 @@ from druks.testing import make_settings -class XMe: - name = "x_me" - x = BrowserSession(site="x.com", persist=True) - docs = BrowserSession(site="docs.example") +@pytest.fixture +def x_me(browser_session_declarations): + class XMe: + name = "x_me" + x = BrowserSession(site="x.com", persist=True) + docs = BrowserSession(site="docs.example") + + return XMe class FakeListener: @@ -101,7 +105,7 @@ async def ephemeral(*, image_override, provider): def stored_session( declaration: BrowserSession, payload: bytes = b"stored-state" ) -> StoredBrowserSession: - row = StoredBrowserSession.create( + row = StoredBrowserSession.get_or_create( name=declaration.name, payload_format=BrowserSessionPayloadFormat.STORAGE_STATE, site=declaration.site, @@ -110,16 +114,16 @@ def stored_session( return row -def test_declaration_carries_the_extension_namespace(): - assert XMe.x.name == "x_me.x" - assert XMe.docs.name == "x_me.docs" +def test_declaration_carries_the_extension_namespace(x_me): + assert x_me.x.name == "x_me.x" + assert x_me.docs.name == "x_me.docs" -async def test_borrow_yields_a_tunneled_cdp_url(borrow): +async def test_borrow_yields_a_tunneled_cdp_url(borrow, x_me): browser, redis = borrow - stored_session(XMe.docs) + stored_session(x_me.docs) - async with XMe.docs.cdp() as cdp_url: + async with x_me.docs.cdp() as cdp_url: assert cdp_url == "http://127.0.0.1:43987" assert browser.forwarded_port == 9222 @@ -133,7 +137,7 @@ async def test_borrow_yields_a_tunneled_cdp_url(borrow): launch_script = browser.commands[0][2] assert "session-launch --headed" in launch_script assert not redis.values - assert StoredBrowserSession.get_for_name(XMe.docs.name).last_used_at + assert StoredBrowserSession.get_for_name(x_me.docs.name).last_used_at async def test_headless_declaration_launches_headless(borrow): @@ -149,83 +153,85 @@ async def test_headless_declaration_launches_headless(borrow): assert "session-launch --headless" in browser.commands[0][2] -async def test_persisting_borrow_locks_exports_and_stores(borrow): +async def test_persisting_borrow_locks_exports_and_stores(borrow, x_me): browser, redis = borrow - row = stored_session(XMe.x) + row = stored_session(x_me.x) - async with XMe.x.cdp(): + async with x_me.x.cdp(): assert redis.values assert not redis.values assert browser.commands[-1] == ["session-export"] db_session().expire_all() - stored = StoredBrowserSession.get_for_name(XMe.x.name) + stored = StoredBrowserSession.get_for_name(x_me.x.name) assert stored.payload.decrypt() == b"exported-profile" assert stored.payload_format == BrowserSessionPayloadFormat.PROFILE_DIR.value assert stored.id == row.id -async def test_persisting_borrow_refuses_a_second_writer(borrow): +async def test_persisting_borrow_refuses_a_second_writer(borrow, x_me): browser, redis = borrow - stored_session(XMe.x) - redis.values[f"browser_session:{StoredBrowserSession.get_for_name(XMe.x.name).id}"] = "other" + stored_session(x_me.x) + redis.values[f"browser_session:{StoredBrowserSession.get_for_name(x_me.x.name).id}"] = "other" with pytest.raises(BrowserSessionWriterLockedError): - async with XMe.x.cdp(): + async with x_me.x.cdp(): pass assert browser.commands == [] -async def test_first_borrow_writes_the_declared_session_and_asks_for_a_login(borrow, druks_db): - """Nothing is stored until a run reaches for the login: the first borrow - writes the row so the pane can ask the operator to sign in, and says so.""" - assert not StoredBrowserSession.get_for_name(XMe.docs.name) +async def test_first_borrow_writes_the_declared_session_and_asks_for_a_login( + borrow, x_me, druks_db +): + """The first borrow materializes the row and refuses to open a browser: + the session is declared, but nobody has signed into it yet.""" + assert not StoredBrowserSession.get_for_name(x_me.docs.name) with pytest.raises(BrowserSessionNotReadyError): - async with XMe.docs.cdp(): + async with x_me.docs.cdp(): pass - row = StoredBrowserSession.get_for_name(XMe.docs.name) + row = StoredBrowserSession.get_for_name(x_me.docs.name) assert row.status == BrowserSessionStatus.NEEDS_LOGIN.value - assert row.site == XMe.docs.site + assert row.site == x_me.docs.site with pytest.raises(BrowserSessionNotReadyError): - async with XMe.docs.cdp(): + async with x_me.docs.cdp(): pass assert StoredBrowserSession.list_all() == [row] -async def test_launch_failure_raises_and_releases_the_lock(borrow): +async def test_launch_failure_raises_and_releases_the_lock(borrow, x_me): browser, redis = borrow - stored_session(XMe.x) + stored_session(x_me.x) browser.launch_exit = 1 with pytest.raises(BrowserLaunchError, match="launch stderr"): - async with XMe.x.cdp(): + async with x_me.x.cdp(): pass assert not redis.values -def test_mark_stale_flags_the_row(borrow): - stored_session(XMe.docs) +def test_mark_stale_flags_the_row(borrow, x_me): + stored_session(x_me.docs) - XMe.docs.mark_stale() + x_me.docs.mark_stale() assert ( - StoredBrowserSession.get_for_name(XMe.docs.name).status == BrowserSessionStatus.STALE.value + StoredBrowserSession.get_for_name(x_me.docs.name).status == BrowserSessionStatus.STALE.value ) -async def test_playwright_yields_the_logged_in_context(borrow, monkeypatch): +async def test_playwright_yields_the_logged_in_context(borrow, x_me, monkeypatch): import sys import types from contextlib import asynccontextmanager as acm browser, _ = borrow - stored_session(XMe.docs) + stored_session(x_me.docs) seen = {} logged_in_context = object() @@ -249,19 +255,19 @@ async def fake_playwright(): monkeypatch.setitem(sys.modules, "playwright", types.ModuleType("playwright")) monkeypatch.setitem(sys.modules, "playwright.async_api", playwright_module) - async with XMe.docs.playwright() as context: + async with x_me.docs.playwright() as context: assert context is logged_in_context assert seen == {"url": "http://127.0.0.1:43987", "closed": True} -async def test_playwright_without_the_dependency_names_the_fix(borrow, monkeypatch): +async def test_playwright_without_the_dependency_names_the_fix(borrow, x_me, monkeypatch): import sys - stored_session(XMe.docs) + stored_session(x_me.docs) monkeypatch.setitem(sys.modules, "playwright", None) monkeypatch.setitem(sys.modules, "playwright.async_api", None) with pytest.raises(BrowserClientMissingError, match="add playwright"): - async with XMe.docs.playwright(): + async with x_me.docs.playwright(): pass diff --git a/backend/tests/test_browser_session_login_window.py b/backend/tests/test_browser_session_login_window.py index 7ad2090e..69676314 100644 --- a/backend/tests/test_browser_session_login_window.py +++ b/backend/tests/test_browser_session_login_window.py @@ -80,7 +80,7 @@ def window_runtime(tmp_path, monkeypatch): def create_session(name: str = "x-main") -> StoredBrowserSession: - return StoredBrowserSession.create( + return StoredBrowserSession.get_or_create( name=name, payload_format=BrowserSessionPayloadFormat.STORAGE_STATE, site="x.com", @@ -100,7 +100,7 @@ async def test_open_seeds_a_blank_profile_and_records_the_container(window_runti } assert STATE_PATH not in browser.files assert PROFILE_PATH not in browser.files - assert (await LoginWindow.get_for_session(session.id)).host_id == browser.id + assert (await LoginWindow.get_for_session(session.name)).host_id == browser.id assert client.provisions == [ {"image_override": "ghcr.io/czpython/druks-browser:latest", "provider": "docker"} ] @@ -114,7 +114,7 @@ async def test_reopening_disposes_the_previous_window(window_runtime): await LoginWindow.open(session) assert client.released == ["browser-1"] - assert (await LoginWindow.get_for_session(session.id)).host_id == "browser-2" + assert (await LoginWindow.get_for_session(session.name)).host_id == "browser-2" async def test_storage_state_reconnect_saves_a_profile(window_runtime): @@ -131,16 +131,16 @@ async def test_storage_state_reconnect_saves_a_profile(window_runtime): "version": 1, } - saved = await (await LoginWindow.get_for_session(session.id)).save() + saved = await (await LoginWindow.get_for_session(session.name)).save() assert saved.payload_format == BrowserSessionPayloadFormat.PROFILE_DIR db_session().expire_all() - stored = StoredBrowserSession.get_for_id(session.id) + stored = StoredBrowserSession.get_for_name(session.name) assert stored.status == BrowserSessionStatus.READY.value assert stored.payload.decrypt() == b"fresh-profile" assert client.released == [browser.id] with pytest.raises(BrowserLoginWindowGoneError): - await LoginWindow.get_for_session(session.id) + await LoginWindow.get_for_session(session.name) async def test_failed_export_closes_the_window(window_runtime): @@ -150,11 +150,11 @@ async def test_failed_export_closes_the_window(window_runtime): client.browsers[0].export_exit_code = 1 with pytest.raises(BrowserExportError): - await (await LoginWindow.get_for_session(session.id)).save() + await (await LoginWindow.get_for_session(session.name)).save() assert client.released == [client.browsers[0].id] with pytest.raises(BrowserLoginWindowGoneError): - await LoginWindow.get_for_session(session.id) + await LoginWindow.get_for_session(session.name) async def test_cancel_then_cancel_again_reports_the_window_gone(window_runtime): @@ -162,11 +162,11 @@ async def test_cancel_then_cancel_again_reports_the_window_gone(window_runtime): session = create_session() await LoginWindow.open(session) - await (await LoginWindow.get_for_session(session.id)).cancel() + await (await LoginWindow.get_for_session(session.name)).cancel() assert client.released == [client.browsers[0].id] with pytest.raises(BrowserLoginWindowGoneError): - await LoginWindow.get_for_session(session.id) + await LoginWindow.get_for_session(session.name) async def test_get_for_session_reports_a_missing_window(): diff --git a/backend/tests/test_browser_sessions.py b/backend/tests/test_browser_sessions.py index 5a1ae935..042d34bb 100644 --- a/backend/tests/test_browser_sessions.py +++ b/backend/tests/test_browser_sessions.py @@ -6,6 +6,7 @@ from druks.browser import routes from druks.browser.enums import BrowserSessionPayloadFormat, BrowserSessionStatus from druks.browser.models import StoredBrowserSession +from druks.browser.sessions import BrowserSession from druks.database import db_session from druks.secrets import utils as secret_utils from druks.secrets.exceptions import SecretDecryptError @@ -14,6 +15,16 @@ from sqlalchemy import text +@pytest.fixture +def x_me(browser_session_declarations): + class XMe: + name = "x_me" + x = BrowserSession(site="x.com") + docs = BrowserSession(site="docs.example") + + return XMe + + @pytest.fixture def client(tmp_path, druks_db, monkeypatch): settings = make_settings(tmp_path) @@ -27,133 +38,141 @@ def client(tmp_path, druks_db, monkeypatch): app.dependency_overrides.pop(dependency, None) -def create_session(name: str = "x-main") -> StoredBrowserSession: - return StoredBrowserSession.create( - name=name, - payload_format=BrowserSessionPayloadFormat.STORAGE_STATE, - site="x.com", - ) +class FakeLoginWindow: + opened: list[str] = [] + + @classmethod + async def open(cls, session) -> None: + cls.opened.append(session.name) + + +def test_declared_sessions_list_without_a_row_and_the_pane_read_writes_nothing(client, x_me): + listed = client.get("/api/browser-sessions").json() + + assert [entry["name"] for entry in listed] == ["x_me.docs", "x_me.x"] + entry = listed[1] + assert entry["status"] == BrowserSessionStatus.NEEDS_LOGIN + assert entry["isDeclared"] is True + assert entry["payloadFormat"] is None + assert entry["createdAt"] is None + assert entry["site"] == "x.com" + assert not StoredBrowserSession.list_all() -def test_import_survives_restart_and_delete_removes_the_row(client, tmp_path, monkeypatch): - created = client.post( - "/api/browser-sessions", - json={"name": "x-main", "payloadFormat": "storage_state", "site": "x.com"}, +def test_leftover_rows_list_as_undeclared_and_refuse_the_login_window(client, x_me): + StoredBrowserSession.get_or_create( + name="gone_ext.old", + payload_format=BrowserSessionPayloadFormat.PROFILE_DIR, + site="gone.example", ) - assert created.status_code == 200 - assert created.json()["status"] == BrowserSessionStatus.NEEDS_LOGIN + listed = client.get("/api/browser-sessions").json() + assert [(entry["name"], entry["isDeclared"]) for entry in listed] == [ + ("x_me.docs", True), + ("x_me.x", True), + ("gone_ext.old", False), + ] + + assert client.post("/api/browser-sessions/gone_ext.old/login-window").status_code == 404 + assert client.delete("/api/browser-sessions/gone_ext.old").status_code == 204 + assert not StoredBrowserSession.list_all() + + +def test_opening_the_login_window_materializes_the_declared_row(client, x_me, monkeypatch): + monkeypatch.setattr(routes, "LoginWindow", FakeLoginWindow) + monkeypatch.setattr(FakeLoginWindow, "opened", []) + + opened = client.post("/api/browser-sessions/x_me.x/login-window") + + assert opened.status_code == 204 + assert FakeLoginWindow.opened == ["x_me.x"] + row = StoredBrowserSession.get_for_name("x_me.x") + assert row.status == BrowserSessionStatus.NEEDS_LOGIN.value + assert row.site == "x.com" + + assert client.post("/api/browser-sessions/nobody.home/login-window").status_code == 404 + + +def test_import_materializes_the_row_survives_restart_and_delete_removes_it( + client, x_me, tmp_path, monkeypatch +): payload = b'{"cookies":[{"name":"auth_token","value":"secret"}],"origins":[]}' - session_id = created.json()["id"] uploaded = client.put( - f"/api/browser-sessions/{session_id}/state", + "/api/browser-sessions/x_me.x/state?payloadFormat=storage_state", content=payload, headers={"Content-Type": "application/octet-stream"}, ) - assert uploaded.status_code == 200 - assert uploaded.json()["status"] == BrowserSessionStatus.READY - assert uploaded.json()["lastRefreshedAt"] + assert uploaded.status_code == 204 + listed = {entry["name"]: entry for entry in client.get("/api/browser-sessions").json()} + assert listed["x_me.x"]["status"] == BrowserSessionStatus.READY + assert listed["x_me.x"]["payloadFormat"] == BrowserSessionPayloadFormat.STORAGE_STATE + assert listed["x_me.x"]["lastRefreshedAt"] + row = StoredBrowserSession.get_for_name("x_me.x") stored = ( db_session() .execute( text("SELECT payload FROM browser_sessions WHERE id = :id"), - {"id": session_id}, + {"id": row.id}, ) .scalar_one() ) assert payload not in bytes(stored) with pytest.raises(SecretDecryptError): secret_utils.decrypt(bytes(stored), "another_table.payload") - - browser_session = StoredBrowserSession.get_for_id(session_id) - assert browser_session.payload.decrypt() == payload + assert row.payload.decrypt() == payload wrong_key = base64.b64encode(b"1" * 32).decode() wrong_settings = make_settings(tmp_path / "wrong", secrets={"secrets_key": wrong_key}) with monkeypatch.context() as patch: patch.setattr(secret_utils, "load_settings", lambda: wrong_settings) with pytest.raises(SecretDecryptError): - browser_session.payload.decrypt() + row.payload.decrypt() db_session().expire_all() - restarted = StoredBrowserSession.get_for_id(session_id) + restarted = StoredBrowserSession.get_for_name("x_me.x") assert restarted.payload.decrypt() == payload - deleted = client.delete(f"/api/browser-sessions/{session_id}") - assert deleted.status_code == 204 - assert not StoredBrowserSession.list_all() - - -def test_create_list_get_and_rename(client): - created = client.post( - "/api/browser-sessions", - json={"name": "linkedin", "payloadFormat": "profile_dir", "site": "linkedin.com"}, + undeclared = client.put( + "/api/browser-sessions/nobody.home/state?payloadFormat=storage_state", content=b"x" ) - assert created.status_code == 200 - session_id = created.json()["id"] - assert created.json()["payloadFormat"] == BrowserSessionPayloadFormat.PROFILE_DIR - - renamed = client.patch( - f"/api/browser-sessions/{session_id}", - json={"name": "linkedin-sales"}, - ) - assert renamed.status_code == 200 - assert renamed.json()["name"] == "linkedin-sales" - assert client.get(f"/api/browser-sessions/{session_id}").json()["name"] == "linkedin-sales" - assert [row["name"] for row in client.get("/api/browser-sessions").json()] == ["linkedin-sales"] - - -def test_duplicate_create_conflicts(client): - body = {"name": "x-main", "site": "x.com"} - assert client.post("/api/browser-sessions", json=body).status_code == 200 - assert client.post("/api/browser-sessions", json=body).status_code == 409 - + assert undeclared.status_code == 404 -@pytest.mark.parametrize("name", ["", "Has Caps", "starts_with_underscore", "ends-"]) -def test_create_rejects_names_that_are_not_slugs(client, name): - response = client.post( - "/api/browser-sessions", - json={"name": name, "payloadFormat": "storage_state", "site": "x.com"}, - ) - assert response.status_code == 422 + deleted = client.delete("/api/browser-sessions/x_me.x") + assert deleted.status_code == 204 + assert not StoredBrowserSession.list_all() -def test_upload_rejects_payloads_above_the_cap(client, monkeypatch): - created = client.post( - "/api/browser-sessions", - json={"name": "x-main", "payloadFormat": "storage_state", "site": "x.com"}, - ).json() +def test_upload_rejects_payloads_above_the_cap(client, x_me, monkeypatch): monkeypatch.setattr(routes, "MAX_PAYLOAD_BYTES", 3) - response = client.put(f"/api/browser-sessions/{created['id']}/state", content=b"four") + response = client.put( + "/api/browser-sessions/x_me.x/state?payloadFormat=storage_state", content=b"four" + ) assert response.status_code == 413 assert "256 MB" in response.json()["detail"] - browser_session = StoredBrowserSession.get_for_id(created["id"]) - assert browser_session.status == BrowserSessionStatus.NEEDS_LOGIN + listed = {entry["name"]: entry for entry in client.get("/api/browser-sessions").json()} + assert listed["x_me.x"]["status"] == BrowserSessionStatus.NEEDS_LOGIN -def test_upload_warns_at_the_product_threshold(client, monkeypatch, caplog): - created = client.post( - "/api/browser-sessions", - json={"name": "x-main", "payloadFormat": "storage_state", "site": "x.com"}, - ).json() +def test_upload_warns_at_the_product_threshold(client, x_me, monkeypatch, caplog): monkeypatch.setattr(routes, "PAYLOAD_WARNING_BYTES", 3) with caplog.at_level("WARNING"): - response = client.put(f"/api/browser-sessions/{created['id']}/state", content=b"three") + response = client.put( + "/api/browser-sessions/x_me.x/state?payloadFormat=storage_state", content=b"three" + ) - assert response.status_code == 200 + assert response.status_code == 204 assert "received a 5-byte payload" in caplog.text -def test_bearer_pat_reads_sessions_but_cannot_mutate_them(tmp_path, druks_db): +def test_bearer_pat_reads_sessions_but_cannot_mutate_them(tmp_path, druks_db, x_me): settings = make_settings( tmp_path, identity={"mode": "header", "header": "X-Edge-Email"}, ) - browser_session = create_session() account = Account.get_or_create("op@example.com") _, token = PersonalAccessToken.create(account_id=account.id, name="agent") db_session().commit() @@ -162,22 +181,13 @@ def test_bearer_pat_reads_sessions_but_cannot_mutate_them(tmp_path, druks_db): with TestClient(configure_app_for_test(settings=settings, authenticated=False)) as pat_client: assert pat_client.get("/api/browser-sessions", headers=headers).status_code == 200 mutations = ( - pat_client.post( - "/api/browser-sessions", - json={"name": "blocked", "payloadFormat": "storage_state", "site": "x.com"}, - headers=headers, - ), pat_client.put( - f"/api/browser-sessions/{browser_session.id}/state", + "/api/browser-sessions/x_me.x/state?payloadFormat=storage_state", content=b"blocked", headers=headers, ), - pat_client.patch( - f"/api/browser-sessions/{browser_session.id}", - json={"name": "blocked"}, - headers=headers, - ), - pat_client.delete(f"/api/browser-sessions/{browser_session.id}", headers=headers), + pat_client.post("/api/browser-sessions/x_me.x/login-window", headers=headers), + pat_client.delete("/api/browser-sessions/x_me.x", headers=headers), ) - assert [response.status_code for response in mutations] == [401, 401, 401, 401] + assert [response.status_code for response in mutations] == [401, 401, 401] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e2aa974e..8f84382f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -212,8 +212,8 @@ function AppShell() { - - {(params) => } + + {(params) => } {registered.flatMap((name) => (getExtensionUI(name)?.routes ?? []).map((route) => ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7cfe2d20..a3457553 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,7 +4,6 @@ import type { ArtifactContent, BrowserSession, ConnectChallenge, - CreateBrowserSessionRequest, DashboardHealth, Extension, FeedResponse, @@ -226,24 +225,18 @@ export const api = { connectService: (name: string, fields: Record) => postJSON(`/api/services/${encodeURIComponent(name)}`, fields), browserSessions: () => getJSON('/api/browser-sessions'), - browserSession: (id: string) => - getJSON(`/api/browser-sessions/${encodeURIComponent(id)}`), - createBrowserSession: (body: CreateBrowserSessionRequest) => - postJSON('/api/browser-sessions', body), - renameBrowserSession: (id: string, name: string) => - patchJSON(`/api/browser-sessions/${encodeURIComponent(id)}`, { name }), - deleteBrowserSession: (id: string) => - deleteRequest(`/api/browser-sessions/${encodeURIComponent(id)}`), - openBrowserSessionLoginWindow: (id: string) => - postNoContent(`/api/browser-sessions/${encodeURIComponent(id)}/login-window`, undefined), - saveBrowserSessionLoginWindow: (id: string) => - postJSON( - `/api/browser-sessions/${encodeURIComponent(id)}/login-window/save`, + deleteBrowserSession: (name: string) => + deleteRequest(`/api/browser-sessions/${encodeURIComponent(name)}`), + openBrowserSessionLoginWindow: (name: string) => + postNoContent(`/api/browser-sessions/${encodeURIComponent(name)}/login-window`, undefined), + saveBrowserSessionLoginWindow: (name: string) => + postNoContent( + `/api/browser-sessions/${encodeURIComponent(name)}/login-window/save`, undefined, ), - cancelBrowserSessionLoginWindow: (id: string) => + cancelBrowserSessionLoginWindow: (name: string) => postNoContent( - `/api/browser-sessions/${encodeURIComponent(id)}/login-window/cancel`, + `/api/browser-sessions/${encodeURIComponent(name)}/login-window/cancel`, undefined, ), getExtensionSettings: () => getJSON('/api/settings/extensions'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 524e0e56..46f07891 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -309,22 +309,19 @@ export type BrowserSessionStatus = 'needs_login' | 'ready' | 'stale' export type BrowserSessionPayloadFormat = 'storage_state' | 'profile_dir' export interface BrowserSession { - id: string name: string status: BrowserSessionStatus - payloadFormat: BrowserSessionPayloadFormat + /** Stored facts arrive with the row; a declared session nobody has acted + * on yet has none. */ + payloadFormat: BrowserSessionPayloadFormat | null site: string - createdAt: string + /** False for a leftover row whose declaring extension is gone. */ + isDeclared: boolean + createdAt: string | null lastRefreshedAt: string | null lastUsedAt: string | null } -export interface CreateBrowserSessionRequest { - name: string - payloadFormat: BrowserSessionPayloadFormat - site: string -} - /** Where an agent's resolved model came from: its own override, or the * family-token default. */ export type ModelSource = 'agent' | 'default' diff --git a/frontend/src/components/BrowserSessionsPane.test.tsx b/frontend/src/components/BrowserSessionsPane.test.tsx index 1cd46be9..4c4cc090 100644 --- a/frontend/src/components/BrowserSessionsPane.test.tsx +++ b/frontend/src/components/BrowserSessionsPane.test.tsx @@ -7,11 +7,11 @@ import { BrowserSessionsPane } from './BrowserSessionsPane' function browserSession(overrides: Partial = {}): BrowserSession { return { - id: 'session-1', - name: 'x-main', + name: 'x_me.x', status: 'ready', payloadFormat: 'storage_state', site: 'x.com', + isDeclared: true, createdAt: new Date().toISOString(), lastRefreshedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), lastUsedAt: null, @@ -27,28 +27,8 @@ function stubFetch(initial: BrowserSession[]) { if (url === '/api/browser-sessions' && method === 'GET') { return new Response(JSON.stringify(sessions), { status: 200 }) } - if (url === '/api/browser-sessions' && method === 'POST') { - const body = JSON.parse(String(init?.body)) - const created = browserSession({ - id: 'session-2', - name: body.name, - status: 'needs_login', - payloadFormat: body.payloadFormat, - site: body.site, - lastRefreshedAt: null, - }) - sessions = [...sessions, created] - return new Response(JSON.stringify(created), { status: 200 }) - } - if (url === '/api/browser-sessions/session-1' && method === 'PATCH') { - const body = JSON.parse(String(init?.body)) - sessions = sessions.map((session) => - session.id === 'session-1' ? { ...session, name: body.name } : session, - ) - return new Response(JSON.stringify(sessions[0]), { status: 200 }) - } - if (url === '/api/browser-sessions/session-1' && method === 'DELETE') { - sessions = sessions.filter((session) => session.id !== 'session-1') + if (url === '/api/browser-sessions/x_me.x' && method === 'DELETE') { + sessions = sessions.filter((session) => session.name !== 'x_me.x') return new Response(null, { status: 204 }) } return new Response('{}', { status: 404 }) @@ -79,8 +59,7 @@ describe('BrowserSessionsPane', () => { stubFetch([ browserSession(), browserSession({ - id: 'session-2', - name: 'linkedin', + name: 'linked_in.jobs', status: 'stale', payloadFormat: 'profile_dir', site: 'linkedin.com', @@ -89,64 +68,59 @@ describe('BrowserSessionsPane', () => { ]) renderPane() - expect(await screen.findByText('x-main')).toBeTruthy() + expect(await screen.findByText('x_me.x')).toBeTruthy() expect(screen.getByText('Ready')).toBeTruthy() expect(screen.getByText('Stale')).toBeTruthy() expect(screen.getByRole('link', { name: 'Reconnect' }).getAttribute('href')).toBe( - '/druks/browser-sessions/session-2/login', + '/druks/browser-sessions/linked_in.jobs/login', ) expect(screen.getByRole('link', { name: 'Open window' }).getAttribute('href')).toBe( - '/druks/browser-sessions/session-1/login', + '/druks/browser-sessions/x_me.x/login', ) expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() - expect(screen.getAllByText('Storage state')).toHaveLength(2) - expect(screen.getAllByText('Profile directory')).toHaveLength(2) + expect(screen.getByText('Storage state')).toBeTruthy() + expect(screen.getByText('Profile directory')).toBeTruthy() expect(screen.getAllByText(/5m ago/)).toHaveLength(2) expect(screen.getByText(/1h ago/)).toBeTruthy() }) - it('creates, renames, and deletes sessions through the session-only routes', async () => { - const fetchMock = stubFetch([browserSession()]) - const confirm = vi.fn(() => true) - vi.stubGlobal('confirm', confirm) + it('shows a declared session with no row as awaiting its first login', async () => { + stubFetch([ + browserSession({ + status: 'needs_login', + payloadFormat: null, + createdAt: null, + lastRefreshedAt: null, + }), + ]) renderPane() - await screen.findByText('x-main') - fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'github-main' } }) - fireEvent.change(screen.getByLabelText(/Site/), { target: { value: 'github.com' } }) - fireEvent.change(screen.getByLabelText('Payload format'), { - target: { value: 'profile_dir' }, - }) - fireEvent.click(screen.getByText('Create session')) - - await screen.findByText('github-main') + expect(await screen.findByText('x_me.x')).toBeTruthy() + expect(screen.getByText('Needs login')).toBeTruthy() expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe( - '/browser-sessions/session-2/login', - ) - const createCall = fetchMock.mock.calls.find( - ([url, init]) => url === '/api/browser-sessions' && init?.method === 'POST', + '/browser-sessions/x_me.x/login', ) - expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ - name: 'github-main', - site: 'github.com', - payloadFormat: 'profile_dir', - }) + expect(screen.queryByText('Delete')).toBeNull() + }) + + it('flags an undeclared leftover row and deletes it by name', async () => { + const fetchMock = stubFetch([browserSession({ isDeclared: false })]) + const confirm = vi.fn(() => true) + vi.stubGlobal('confirm', confirm) + renderPane() + await screen.findByText('x_me.x') - fireEvent.click(screen.getAllByText('Rename')[0]!) - fireEvent.change(screen.getByLabelText('New name for x-main'), { - target: { value: 'x-personal' }, - }) - fireEvent.click(screen.getByText('Save')) - expect(await screen.findByText('x-personal')).toBeTruthy() + expect(screen.getByText('No longer declared')).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Open window' })).toBeNull() - fireEvent.click(screen.getAllByText('Delete')[0]!) + fireEvent.click(screen.getByText('Delete')) expect(confirm).toHaveBeenCalledWith( - 'Delete x-personal? Its saved browser state will be destroyed.', + 'Delete x_me.x? Its saved browser state will be destroyed.', ) - await waitFor(() => expect(screen.queryByText('x-personal')).toBeNull()) + await waitFor(() => expect(screen.queryByText('x_me.x')).toBeNull()) expect( fetchMock.mock.calls.some( - ([url, init]) => url === '/api/browser-sessions/session-1' && init?.method === 'DELETE', + ([url, init]) => url === '/api/browser-sessions/x_me.x' && init?.method === 'DELETE', ), ).toBe(true) }) diff --git a/frontend/src/components/BrowserSessionsPane.tsx b/frontend/src/components/BrowserSessionsPane.tsx index 525df240..8e244c39 100644 --- a/frontend/src/components/BrowserSessionsPane.tsx +++ b/frontend/src/components/BrowserSessionsPane.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api/client' -import { TextInput } from './Control' import type { BrowserSession, BrowserSessionPayloadFormat, @@ -33,51 +32,9 @@ export function BrowserSessionsPane() { queryKey: ['browserSessions'], queryFn: () => api.browserSessions(), }) - const [name, setName] = useState('') - const [site, setSite] = useState('') - const [payloadFormat, setPayloadFormat] = - useState('storage_state') - const [renamingId, setRenamingId] = useState(null) - const [renamedName, setRenamedName] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - const refresh = () => queryClient.invalidateQueries({ queryKey: ['browserSessions'] }) - - async function createSession() { - setBusy(true) - setError(null) - try { - await api.createBrowserSession({ - name: name.trim(), - payloadFormat, - site: site.trim(), - }) - setName('') - setSite('') - await refresh() - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)) - } finally { - setBusy(false) - } - } - - async function renameSession(session: BrowserSession) { - setBusy(true) - setError(null) - try { - await api.renameBrowserSession(session.id, renamedName.trim()) - setRenamingId(null) - setRenamedName('') - await refresh() - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)) - } finally { - setBusy(false) - } - } - async function deleteSession(session: BrowserSession) { if (!window.confirm(`Delete ${session.name}? Its saved browser state will be destroyed.`)) { return @@ -85,8 +42,8 @@ export function BrowserSessionsPane() { setBusy(true) setError(null) try { - await api.deleteBrowserSession(session.id) - await refresh() + await api.deleteBrowserSession(session.name) + await queryClient.invalidateQueries({ queryKey: ['browserSessions'] }) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) } finally { @@ -101,7 +58,7 @@ export function BrowserSessionsPane() {

Browser sessions

- Encrypted browser state kept under stable names. + Sign-ins your extensions declare, kept as encrypted browser state.

@@ -111,79 +68,24 @@ export function BrowserSessionsPane() { )} -
-

Create a session

-

- Give the sign-in a stable slug, then open its login window to authenticate. -

-
-
- - setName(event.target.value)} - disabled={busy} - /> -
-
- - setSite(event.target.value)} - disabled={busy} - /> -
-
- - -
- -
-
-

Sessions {sessions.length}

{!query.isLoading && sessions.length === 0 && ( -

No browser sessions yet.

+

No installed extension declares a browser session.

)} {sessions.length > 0 && (
{sessions.map((session) => ( -
+
{session.name} - - {FORMAT_LABELS[session.payloadFormat]} - + {session.payloadFormat && ( + + {FORMAT_LABELS[session.payloadFormat]} + + )} {session.site}
@@ -197,48 +99,19 @@ export function BrowserSessionsPane() {
{relTimeFromIso(session.lastUsedAt)}
- {renamingId === session.id ? ( -
- setRenamedName(event.target.value)} - disabled={busy} - /> - - -
- ) : ( -
- {/* A full load dismisses Settings before the login window mounts. */} +
+ {session.isDeclared ? ( + /* A full load dismisses Settings before the login window mounts. */ {LOGIN_ACTION_LABELS[session.status]} - + ) : ( + No longer declared + )} + {session.createdAt && ( -
- )} + )} +
))}
diff --git a/frontend/src/components/SettingsModal.test.tsx b/frontend/src/components/SettingsModal.test.tsx index 30751f1a..39aa517f 100644 --- a/frontend/src/components/SettingsModal.test.tsx +++ b/frontend/src/components/SettingsModal.test.tsx @@ -188,7 +188,9 @@ describe('SettingsModal extension fields', () => { fireEvent.click(await screen.findByRole('button', { name: 'Browser sessions' })) expect(await screen.findByRole('heading', { name: 'Browser sessions' })).toBeTruthy() - expect(await screen.findByText('No browser sessions yet.')).toBeTruthy() + expect( + await screen.findByText('No installed extension declares a browser session.'), + ).toBeTruthy() }) it('spells an underscored app name out in the rail and its options group', async () => { diff --git a/frontend/src/pages/LoginWindowPage.test.tsx b/frontend/src/pages/LoginWindowPage.test.tsx index 718ecaa1..3f1d0f05 100644 --- a/frontend/src/pages/LoginWindowPage.test.tsx +++ b/frontend/src/pages/LoginWindowPage.test.tsx @@ -1,5 +1,4 @@ import { StrictMode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -26,12 +25,9 @@ vi.mock('@novnc/novnc/lib/rfb', () => { }) function renderPage() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - - - + , ) } @@ -46,27 +42,12 @@ afterEach(() => { describe('LoginWindowPage', () => { it('opens the one-use bridge and saves the browser profile', async () => { const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( - async (url, init) => { - if (url === '/api/browser-sessions/session-1' && !init?.method) { - return new Response( - JSON.stringify({ - id: 'session-1', - name: 'x-main', - status: 'stale', - payloadFormat: 'storage_state', - site: 'x.com', - createdAt: new Date().toISOString(), - lastRefreshedAt: new Date().toISOString(), - lastUsedAt: null, - }), - { status: 200 }, - ) - } - if (url === '/api/browser-sessions/session-1/login-window') { + async (url) => { + if (url === '/api/browser-sessions/x_me.x/login-window') { return new Response(null, { status: 204 }) } - if (url === '/api/browser-sessions/session-1/login-window/save') { - return new Response(JSON.stringify({ id: 'session-1', status: 'ready' }), { status: 200 }) + if (url === '/api/browser-sessions/x_me.x/login-window/save') { + return new Response(null, { status: 204 }) } return new Response('{}', { status: 404 }) }, @@ -76,11 +57,11 @@ describe('LoginWindowPage', () => { renderPage() - expect(await screen.findByText('x-main')).toBeTruthy() + expect(await screen.findByText('x_me.x')).toBeTruthy() await waitFor(() => expect(rfbState.instances).toHaveLength(1)) expect(fetchMock.mock.calls.filter(([url]) => url.endsWith('/login-window'))).toHaveLength(1) expect(rfbState.instances[0]?.url).toBe( - 'ws://localhost:3000/api/browser-sessions/session-1/login-window/ws', + 'ws://localhost:3000/api/browser-sessions/x_me.x/login-window/ws', ) rfbState.instances[0]?.dispatchEvent(new Event('connect')) expect(await screen.findByText('Connected')).toBeTruthy() @@ -91,7 +72,7 @@ describe('LoginWindowPage', () => { expect( fetchMock.mock.calls.some( ([url, init]) => - url === '/api/browser-sessions/session-1/login-window/save' && init?.method === 'POST', + url === '/api/browser-sessions/x_me.x/login-window/save' && init?.method === 'POST', ), ).toBe(true) }) @@ -102,7 +83,7 @@ describe('LoginWindowPage', () => { return new Response(null, { status: 204 }) } if (url.endsWith('/cancel')) return new Response(null, { status: 204 }) - return new Response(JSON.stringify({ id: 'session-1', name: 'x-main' }), { status: 200 }) + return new Response('{}', { status: 404 }) }) vi.stubGlobal('fetch', fetchMock) const back = vi.spyOn(window.history, 'back').mockImplementation(() => undefined) diff --git a/frontend/src/pages/LoginWindowPage.tsx b/frontend/src/pages/LoginWindowPage.tsx index b1bddd7d..54140ce6 100644 --- a/frontend/src/pages/LoginWindowPage.tsx +++ b/frontend/src/pages/LoginWindowPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react' -import { useQuery } from '@tanstack/react-query' import RFB from '@novnc/novnc/lib/rfb' import { api } from '../api/client' @@ -16,17 +15,13 @@ const CONNECTION_COPY: Record = { } interface Props { - sessionId: string + name: string } -export function LoginWindowPage({ sessionId }: Props) { - const session = useQuery({ - queryKey: ['browserSession', sessionId], - queryFn: () => api.browserSession(sessionId), - }) +export function LoginWindowPage({ name }: Props) { const canvas = useRef(null) const rfb = useRef(null) - const openingSessionId = useRef(sessionId) + const openingName = useRef(name) const opening = useRef | null>(null) const [connection, setConnection] = useState('opening') const [windowOpen, setWindowOpen] = useState(false) @@ -38,17 +33,17 @@ export function LoginWindowPage({ sessionId }: Props) { async function connect() { try { - if (openingSessionId.current !== sessionId) { - openingSessionId.current = sessionId + if (openingName.current !== name) { + openingName.current = name opening.current = null } - opening.current ??= api.openBrowserSessionLoginWindow(sessionId) + opening.current ??= api.openBrowserSessionLoginWindow(name) await opening.current if (!active || !canvas.current) return setWindowOpen(true) setConnection('connecting') const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws' - const path = `/api/browser-sessions/${encodeURIComponent(sessionId)}/login-window/ws` + const path = `/api/browser-sessions/${encodeURIComponent(name)}/login-window/ws` const socketUrl = `${scheme}://${window.location.host}${path}` const client = new RFB(canvas.current, socketUrl) client.scaleViewport = true @@ -71,13 +66,13 @@ export function LoginWindowPage({ sessionId }: Props) { rfb.current?.disconnect() rfb.current = null } - }, [sessionId]) + }, [name]) async function save() { setAction('save') setError(null) try { - await api.saveBrowserSessionLoginWindow(sessionId) + await api.saveBrowserSessionLoginWindow(name) window.history.back() } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) @@ -89,7 +84,7 @@ export function LoginWindowPage({ sessionId }: Props) { setAction('cancel') setError(null) try { - await api.cancelBrowserSessionLoginWindow(sessionId) + await api.cancelBrowserSessionLoginWindow(name) window.history.back() } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) @@ -97,7 +92,6 @@ export function LoginWindowPage({ sessionId }: Props) { } } - const title = session.data?.name ?? 'Browser session' const busy = action !== null return ( @@ -105,7 +99,7 @@ export function LoginWindowPage({ sessionId }: Props) {
browser session -

{title}

+

{name}

{CONNECTION_COPY[connection]}

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 30b918e3..95a420c5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1242,8 +1242,6 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; .svc-alt:hover { color: var(--text); } .svc-pane a.set-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.browser-session-create { display: grid; grid-template-columns: minmax(150px, 0.8fr) minmax(180px, 1fr) minmax(150px, 0.8fr) auto; gap: 12px; align-items: end; } -.browser-session-create-button { white-space: nowrap; } .browser-session-list { display: flex; flex-direction: column; gap: 8px; } .browser-session-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px 18px; padding: 13px 14px; } .browser-session-identity { display: flex; align-items: baseline; gap: 9px; min-width: 0; } @@ -1260,7 +1258,7 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; .browser-session-times dt { font-size: 11.5px; color: var(--text-faint); } .browser-session-times dd { margin: 0; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-mid); } .browser-session-actions { display: flex; justify-content: flex-end; gap: 8px; } -.browser-session-rename { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; } +.browser-session-undeclared { align-self: center; font-size: 11.5px; color: var(--text-faint); } .page-login-window .page-shell-body { flex: 1; min-height: 0; background: #080a0c; } .login-window-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 20px; border-bottom: 1px solid var(--border); background: var(--surface); } @@ -1279,7 +1277,6 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; @media (max-width: 720px) { .set-extension-toggles { grid-template-columns: 1fr; } .svc-grid { grid-template-columns: 1fr; } - .browser-session-create { grid-template-columns: 1fr 1fr; } .set-thead { display: none; } .set-trow { grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 14px; } .skill-add { flex-direction: column; align-items: stretch; } @@ -1297,7 +1294,6 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; } @media (max-width: 460px) { - .browser-session-create { grid-template-columns: 1fr; } .browser-session-times { flex-direction: column; gap: 5px; } } diff --git a/scripts/import_browser_session.py b/scripts/import_browser_session.py index 9a3c2f97..838ff336 100755 --- a/scripts/import_browser_session.py +++ b/scripts/import_browser_session.py @@ -15,7 +15,9 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Capture a headed Chromium login and import it into the Druks session vault." ) - parser.add_argument("--name", required=True, help="Browser-session slug, for example x-main.") + parser.add_argument( + "--name", required=True, help="Declared browser-session name, for example x_me.x." + ) parser.add_argument("--site-url", required=True, help="Login page to open in Chromium.") parser.add_argument( "--druks-url", @@ -62,13 +64,14 @@ def request_json( except urllib.error.URLError as error: raise RuntimeError(f"Could not reach Druks at {base_url}: {error.reason}") from error with response: - try: - return json.load(response) - except json.JSONDecodeError as error: - raise RuntimeError( - f"Druks returned a non-JSON response for {method} {path}; " - "check the dashboard URL and session headers." - ) from error + body = response.read() + try: + return json.loads(body) if body else None + except json.JSONDecodeError as error: + raise RuntimeError( + f"Druks returned a non-JSON response for {method} {path}; " + "check the dashboard URL and session headers." + ) from error def load_playwright() -> Any: @@ -94,26 +97,10 @@ def main() -> int: sync_playwright = load_playwright() sessions = request_json(args.druks_url, "/api/browser-sessions", headers=headers) - browser_session = next((row for row in sessions if row["name"] == args.name), None) - if browser_session: - if browser_session["payloadFormat"] != "storage_state": - raise RuntimeError( - f"Browser session {args.name!r} uses {browser_session['payloadFormat']!r}; " - "this importer captures storage_state." - ) - else: - browser_session = request_json( - args.druks_url, - "/api/browser-sessions", - headers=headers, - method="POST", - data=json.dumps( - { - "name": args.name, - "payloadFormat": "storage_state", - "site": urllib.parse.urlparse(args.site_url).hostname, - } - ).encode(), + if args.name not in {row["name"] for row in sessions if row["isDeclared"]}: + raise RuntimeError( + f"Browser session {args.name!r} is not declared by any installed " + "extension; declare it on the extension class first." ) with tempfile.TemporaryDirectory(prefix="druks-browser-session-") as temporary_directory: @@ -127,16 +114,17 @@ def main() -> int: context.storage_state(path=str(storage_state_path)) browser.close() - imported = request_json( + request_json( args.druks_url, - f"/api/browser-sessions/{browser_session['id']}/state", + f"/api/browser-sessions/{urllib.parse.quote(args.name)}/state" + "?payloadFormat=storage_state", headers=headers, method="PUT", data=storage_state_path.read_bytes(), content_type="application/octet-stream", ) - print(f"Imported {imported['name']}: {imported['status']}") + print(f"Imported {args.name}.") return 0