feat: remote browser login persistence + balance display + UI consistency
- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
This commit is contained in:
@@ -37,11 +37,18 @@ class BrowserSession:
|
||||
|
||||
|
||||
class BrowserSessionService:
|
||||
# Idle TTL: close sessions that haven't had activity for this long
|
||||
IDLE_TTL_SECONDS = 1800 # 30 minutes
|
||||
# Cap: max concurrent persistent sessions (excludes auth-capture)
|
||||
MAX_SESSIONS = 10
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._playwright: Optional[Any] = None
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._profiles: dict[str, str] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_event_at: dict[str, float] = {}
|
||||
self._evict_task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -61,21 +68,43 @@ class BrowserSessionService:
|
||||
existing_id = self._profiles.get(profile_key)
|
||||
existing = self._sessions.get(existing_id or "")
|
||||
if existing and not existing.page.is_closed():
|
||||
async with existing.lock:
|
||||
await existing.page.set_viewport_size({"width": width, "height": height})
|
||||
if existing.page.url == "about:blank":
|
||||
await existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
await self._autofill_login(existing.page, login_config)
|
||||
await self._reset_page_zoom(existing)
|
||||
# Health check: verify session can actually serve content
|
||||
healthy = True
|
||||
try:
|
||||
async with existing.lock:
|
||||
url_before = existing.page.url
|
||||
await existing.page.evaluate("1") # ping
|
||||
await existing.page.screenshot(type="jpeg", quality=10, timeout=5000)
|
||||
await existing.page.set_viewport_size({"width": width, "height": height})
|
||||
if url_before == "about:blank":
|
||||
await existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
await self._autofill_login(existing.page, login_config)
|
||||
await self._reset_page_zoom(existing)
|
||||
self._touch(existing.id)
|
||||
except Exception:
|
||||
logger.info("existing session %s unhealthy, recreating", existing.id[:12])
|
||||
healthy = False
|
||||
if healthy:
|
||||
return existing
|
||||
# Close unhealthy session (profile stays on disk)
|
||||
await self.close(existing.id)
|
||||
if existing_id:
|
||||
self._profiles.pop(profile_key, None)
|
||||
# Idle cleanup: close stale sessions before spawning new ones
|
||||
await self._evict_idle_sessions()
|
||||
context = await self._playwright.chromium.launch_persistent_context(
|
||||
str(self._profile_dir(profile_key)),
|
||||
headless=get_settings().browser_headless,
|
||||
viewport={"width": width, "height": height},
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
# Grant clipboard access for the page origin
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||
except Exception:
|
||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
session = BrowserSession(
|
||||
id=uuid4().hex,
|
||||
@@ -87,6 +116,9 @@ class BrowserSessionService:
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
self._profiles[profile_key] = session.id
|
||||
self._touch(session.id)
|
||||
# Evict again after adding the new session so cap is enforced immediately
|
||||
await self._evict_idle_sessions()
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
await self._autofill_login(page, login_config)
|
||||
@@ -96,8 +128,13 @@ class BrowserSessionService:
|
||||
raise
|
||||
return session
|
||||
|
||||
def _touch(self, session_id: str) -> None:
|
||||
"""Mark a session as recently active (reset idle timer)."""
|
||||
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
||||
|
||||
async def screenshot(self, session_id: str) -> bytes:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
|
||||
@@ -111,6 +148,7 @@ class BrowserSessionService:
|
||||
include_state: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
session = self._get(session_id)
|
||||
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
page = session.page
|
||||
@@ -156,12 +194,51 @@ class BrowserSessionService:
|
||||
|
||||
async def selected_text(self, session_id: str) -> str:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
|
||||
return str(value or "")
|
||||
|
||||
async def read_clipboard(self, session_id: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Read the remote browser's clipboard text.
|
||||
|
||||
Returns (text, error_reason).
|
||||
text is None when the clipboard is empty or unreadable.
|
||||
error_reason is None on success or "empty" — non-None indicates a genuine failure.
|
||||
"""
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
try:
|
||||
result = await session.page.evaluate("""
|
||||
async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return text || null;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException) {
|
||||
if (e.name === 'NotAllowedError') return 'ERROR:denied';
|
||||
if (e.name === 'NotFoundError') return null;
|
||||
}
|
||||
return 'ERROR:' + (e.message || String(e));
|
||||
}
|
||||
}
|
||||
""")
|
||||
if result is None:
|
||||
return None, None # empty clipboard
|
||||
if isinstance(result, str) and result.startswith("ERROR:"):
|
||||
reason = result[6:]
|
||||
logger.debug("clipboard read error for %s: %s", session_id[:12], reason)
|
||||
return None, reason
|
||||
return str(result), None
|
||||
except Exception as exc:
|
||||
logger.warning("clipboard read failed for %s: %s", session_id[:12], exc)
|
||||
return None, "read_failed"
|
||||
|
||||
async def close(self, session_id: str) -> None:
|
||||
self._last_event_at.pop(session_id, None)
|
||||
session = self._discard_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
@@ -185,6 +262,14 @@ class BrowserSessionService:
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
# Cancel the background eviction loop
|
||||
if self._evict_task is not None and not self._evict_task.done():
|
||||
self._evict_task.cancel()
|
||||
try:
|
||||
await self._evict_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._evict_task = None
|
||||
sessions = list(self._sessions)
|
||||
for session_id in sessions:
|
||||
await self.close(session_id)
|
||||
@@ -194,6 +279,7 @@ class BrowserSessionService:
|
||||
|
||||
async def state(self, session_id: str) -> dict[str, Any]:
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await self._session_state(session)
|
||||
@@ -217,6 +303,9 @@ class BrowserSessionService:
|
||||
self._playwright = await async_playwright().start()
|
||||
except Exception as exc:
|
||||
raise BrowserDependencyError(f"Unable to start Playwright: {exc}") from exc
|
||||
# Start background eviction loop
|
||||
if self._evict_task is None or self._evict_task.done():
|
||||
self._evict_task = asyncio.create_task(self._evict_loop())
|
||||
|
||||
async def _reset_page_zoom(self, session: BrowserSession) -> None:
|
||||
try:
|
||||
@@ -228,20 +317,38 @@ class BrowserSessionService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def autofill_login(
|
||||
self,
|
||||
session_id: str,
|
||||
login_config: Optional[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Public: manually trigger login autofill for an active session.
|
||||
|
||||
Only fills username/password fields — never auto-submits.
|
||||
Returns True if fields were found and filled, False otherwise.
|
||||
Never returns password data to the caller.
|
||||
"""
|
||||
session = self._get(session_id)
|
||||
self._touch(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await self._autofill_login(session.page, login_config, max_wait_seconds=3.0, skip_submit=True)
|
||||
|
||||
async def _autofill_login(
|
||||
self,
|
||||
page: Any,
|
||||
config: Optional[dict[str, Any]],
|
||||
*,
|
||||
max_wait_seconds: float = 8.0,
|
||||
max_wait_seconds: float = 2.0,
|
||||
poll_interval_seconds: float = 0.25,
|
||||
) -> None:
|
||||
skip_submit: bool = False,
|
||||
) -> bool:
|
||||
if not config or not config.get("enabled"):
|
||||
return
|
||||
return False
|
||||
username = str(config.get("username") or "")
|
||||
password = str(config.get("password") or "")
|
||||
if not username or not password:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
username_selectors = [
|
||||
config.get("username_selector"),
|
||||
@@ -268,17 +375,20 @@ class BrowserSessionService:
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
)
|
||||
if not username_locator or not password_locator:
|
||||
logger.info("Login autofill skipped for %s: login fields not found", page.url)
|
||||
return
|
||||
logger.info("Login autofill skipped: login fields not found")
|
||||
return False
|
||||
await username_locator.fill(username, timeout=3000)
|
||||
await password_locator.fill(password, timeout=3000)
|
||||
submit_selector = str(config.get("submit_selector") or "").strip()
|
||||
if submit_selector:
|
||||
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
|
||||
if submit:
|
||||
await submit.click(timeout=3000)
|
||||
if not skip_submit:
|
||||
submit_selector = str(config.get("submit_selector") or "").strip()
|
||||
if submit_selector:
|
||||
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
|
||||
if submit:
|
||||
await submit.click(timeout=3000)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.info("Login autofill skipped for %s: %s", page.url, exc)
|
||||
logger.info("Login autofill skipped: %s", exc)
|
||||
return False
|
||||
|
||||
async def _wait_for_login_locators(
|
||||
self,
|
||||
@@ -345,6 +455,68 @@ class BrowserSessionService:
|
||||
self._profiles.pop(session.profile_key, None)
|
||||
return session
|
||||
|
||||
async def _evict_loop(self) -> None:
|
||||
"""Background loop that runs every 5 minutes to evict idle sessions."""
|
||||
while True:
|
||||
await asyncio.sleep(300) # 5 minutes
|
||||
try:
|
||||
await self._evict_idle_sessions()
|
||||
except Exception:
|
||||
logger.exception("idle eviction loop error")
|
||||
|
||||
async def _evict_idle_sessions(self) -> None:
|
||||
"""Close oldest idle sessions when over cap, or any past TTL."""
|
||||
now = asyncio.get_event_loop().time()
|
||||
# First: drop sessions past idle TTL (excluding just-created ones)
|
||||
to_remove: list[str] = []
|
||||
for sid, session in self._sessions.items():
|
||||
if session.profile_key and session.profile_key.startswith("auth-capture-"):
|
||||
continue # ephemeral sessions are handled separately
|
||||
last_active = self._last_event_at.get(sid, 0.0)
|
||||
if last_active > 0 and (now - last_active) > self.IDLE_TTL_SECONDS:
|
||||
to_remove.append(sid)
|
||||
for sid in to_remove:
|
||||
logger.info("evicting idle session %s (no activity for >%ds)", sid[:12], self.IDLE_TTL_SECONDS)
|
||||
await self.close(sid)
|
||||
|
||||
# Second: if still over cap, evict oldest by last_event_at
|
||||
persistent = [(sid, s) for sid, s in self._sessions.items()
|
||||
if not (s.profile_key or "").startswith("auth-capture-")]
|
||||
if len(persistent) > self.MAX_SESSIONS:
|
||||
persistent.sort(key=lambda x: self._last_event_at.get(x[0], 0.0))
|
||||
excess = len(persistent) - self.MAX_SESSIONS
|
||||
for sid, _ in persistent[:excess]:
|
||||
logger.info("evicting session %s (over cap of %d)", sid[:12], self.MAX_SESSIONS)
|
||||
await self.close(sid)
|
||||
|
||||
async def clear_profile(self, custom_page_id: int, url: str) -> None:
|
||||
"""Close session for the page if active, then delete profile directory.
|
||||
|
||||
Raises RuntimeError if the directory cannot be fully removed.
|
||||
"""
|
||||
import shutil
|
||||
# Close active session and use its profile_key (precise match)
|
||||
profile_key: Optional[str] = None
|
||||
try:
|
||||
session = self.find_by_page_id(custom_page_id)
|
||||
profile_key = session.profile_key
|
||||
await self.close(session.id)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Fallback: compute from URL (may be wrong if URL changed since session was created)
|
||||
if not profile_key:
|
||||
profile_key = self._profile_key(custom_page_id, url)
|
||||
|
||||
profile_dir = self._profile_dir(profile_key)
|
||||
if profile_dir.exists():
|
||||
shutil.rmtree(profile_dir) # no ignore_errors — let failure surface
|
||||
if profile_dir.exists():
|
||||
raise RuntimeError(
|
||||
f"Failed to fully remove browser profile directory: {profile_dir}"
|
||||
)
|
||||
logger.info("cleared browser profile for page %d: %s", custom_page_id, profile_dir)
|
||||
|
||||
def _profile_dir(self, profile_key: str) -> Path:
|
||||
root = Path(get_settings().browser_profiles_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -383,6 +555,13 @@ class BrowserSessionService:
|
||||
viewport={"width": width, "height": height},
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
# Grant clipboard access for the page origin
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||
except Exception:
|
||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||
page = context.pages[0] if context.pages else await context.new_page()
|
||||
session = BrowserSession(
|
||||
id=session_id,
|
||||
@@ -394,6 +573,7 @@ class BrowserSessionService:
|
||||
captured_headers=[],
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
self._touch(session.id)
|
||||
# Start CDP network capture BEFORE the initial page load,
|
||||
# so we capture login redirects and auth headers from the start.
|
||||
await self._start_cdp_capture(session)
|
||||
|
||||
Reference in New Issue
Block a user