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