mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
163 lines
7.2 KiB
Python
163 lines
7.2 KiB
Python
"""Borrow the sign-in the user already has in their everyday browser, so a browser agent that hits
|
|
a login wall can carry on as them instead of stopping to ask them to log in all over again.
|
|
|
|
The point is that no password is ever typed, stored, or seen. We copy the SESSION the user's real
|
|
Chrome/Arc/Brave/Edge already holds into the app's own browser partition. It is the same mechanism
|
|
onboarding uses to read the user's provider chat history, pointed at whatever site the agent is
|
|
stuck on instead of at a fixed provider list.
|
|
|
|
Four things keep it narrow:
|
|
- Off unless the user turned it on (`browser_import_signins`, default False). Reading their real
|
|
browser is a decision they make once, explicitly, not one we make for them.
|
|
- The domain is never model-chosen. It comes from the URL of the page the agent is already stuck
|
|
on, so no amount of prompt injection can name a site to harvest.
|
|
- Records only ever travel INTO our own partition. Nothing is read back out.
|
|
- Values are never logged. Counts and domains only.
|
|
|
|
Coverage is honestly partial: Chromium-family browsers on macOS/Windows, and not Chrome's newer
|
|
app-bound (v20) stores. Everything else returns `no_session` and the run falls back to asking the
|
|
user to sign in, which is exactly what it did before this existed.
|
|
|
|
This is the ONE module in browser/ that knows where the reader lives, so the reader can move house
|
|
later without anything else noticing.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any, Dict, List, Literal
|
|
from uuid import uuid4
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.browser import browser_login_handoff
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
from backend.apps.onboarding.usage import browser_cookies
|
|
from backend.apps.settings.models import AppSettings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ImportOutcome = Literal["imported", "disabled", "no_session", "bridge_failed"]
|
|
|
|
# Google authenticates on the parent SSO domain, so a Gmail/YouTube/Docs session does not live on
|
|
# the property's own host. The reader already has a named scope for exactly this, and we reuse it
|
|
# rather than sweeping every google entry the user owns.
|
|
P_GOOGLE_SUFFIXES = ("google.com", "youtube.com")
|
|
|
|
# Chromium counts from 1601-01-01 in microseconds, because of course it does. Electron wants unix
|
|
# seconds, and an entry with no expiry is session-scoped, so it would evaporate on the next quit.
|
|
P_CHROMIUM_EPOCH_OFFSET_S = 11644473600
|
|
|
|
# Anti-bot clearance tokens are bound to the exact user agent and IP that earned them. Our webview
|
|
# keeps an "openswarm/" product token in its UA, so a clearance minted by the user's real Chrome can
|
|
# never match ours, and replaying a mismatched one reads as token theft: the edge hands back a fresh
|
|
# challenge instead of letting us through, which is WORSE than arriving with no clearance at all.
|
|
# Everything else in the jar is the actual session, so we carry that and let the edge re-challenge
|
|
# us honestly.
|
|
P_FINGERPRINT_BOUND: set = set()
|
|
|
|
|
|
class SessionImportResult(BaseModel):
|
|
"""What happened, in a shape the caller can branch on without parsing prose."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
outcome: ImportOutcome = "no_session"
|
|
domain: str = ""
|
|
entries_applied: int = 0
|
|
detail: str = ""
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.outcome == "imported"
|
|
|
|
|
|
@typechecked
|
|
def is_enabled(settings: AppSettings) -> bool:
|
|
return bool(settings.browser_import_signins)
|
|
|
|
|
|
@typechecked
|
|
def is_google_property(domain: str) -> bool:
|
|
d = (domain or "").lower().lstrip(".")
|
|
return any(d == s or d.endswith(f".{s}") for s in P_GOOGLE_SUFFIXES)
|
|
|
|
|
|
@typechecked
|
|
def read_site_records(domain: str) -> List[Dict[str, Any]]:
|
|
"""The user's own session records for `domain`. Blocking: touches SQLite and may raise one OS
|
|
keychain consent prompt, so callers must keep it off the event loop."""
|
|
try:
|
|
if is_google_property(domain):
|
|
raw = browser_cookies.read_google_session_records()
|
|
else:
|
|
raw = browser_cookies.read_provider_cookie_records(domain)
|
|
except Exception as exc:
|
|
# A browser we cannot read is a fallback, never a crash: the run just asks the user instead.
|
|
logger.info(f"[session-import] read failed for {domain}: {type(exc).__name__}")
|
|
return []
|
|
return [{**r, "expires": p_unix_expiry(r.get("expires_utc"))} for r in raw
|
|
if str(r.get("name") or "").lower() not in P_FINGERPRINT_BOUND]
|
|
|
|
|
|
@typechecked
|
|
def p_unix_expiry(expires_utc: Any) -> float:
|
|
"""Chromium's stamp as unix seconds, 0.0 for a session-scoped entry (which Electron then leaves
|
|
session-scoped too, so it dies on quit exactly like it would in the source browser)."""
|
|
try:
|
|
raw = int(expires_utc or 0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
return max(0.0, raw / 1_000_000 - P_CHROMIUM_EPOCH_OFFSET_S) if raw > 0 else 0.0
|
|
|
|
|
|
@typechecked
|
|
def site_domain(url_or_host: str) -> str:
|
|
"""Normalise a URL or bare host to the registrable domain the store is keyed by. Delegates so
|
|
there is exactly one definition of 'which site is this' across the browser modules."""
|
|
return browser_login_handoff.registrable_domain(url_or_host)
|
|
|
|
|
|
@typechecked
|
|
def has_importable_session(domain: str) -> bool:
|
|
"""Whether some browser store holds a session for this domain, WITHOUT decrypting anything and
|
|
without touching the keychain. Cheap enough to ask before deciding to interrupt the user."""
|
|
d = site_domain(domain)
|
|
if not d:
|
|
return False
|
|
try:
|
|
return browser_cookies.has_store(".google.com" if is_google_property(d) else d)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@typechecked
|
|
async def import_signin(domain: str, browser_id: str) -> SessionImportResult:
|
|
"""Copy the user's existing sign-in for `domain` into the app's browser partition.
|
|
|
|
Never raises: every failure degrades to a result the caller can fall back from, because that
|
|
fallback (ask the user to sign in) is exactly the behaviour that existed before this did.
|
|
"""
|
|
d = site_domain(domain)
|
|
if not d:
|
|
return SessionImportResult(outcome="no_session", domain=domain, detail="no domain")
|
|
|
|
records = await asyncio.to_thread(read_site_records, d)
|
|
if not records:
|
|
logger.info(f"[session-import] no readable session for {d}")
|
|
return SessionImportResult(outcome="no_session", domain=d,
|
|
detail="no session found in your other browsers")
|
|
|
|
result = await ws_manager.send_browser_command(
|
|
uuid4().hex, "import_session", browser_id, {"domain": d, "cookies": records})
|
|
if not isinstance(result, dict) or result.get("error"):
|
|
detail = str(result.get("error") if isinstance(result, dict) else result)[:200]
|
|
logger.info(f"[session-import] bridge failed for {d}: {detail}")
|
|
return SessionImportResult(outcome="bridge_failed", domain=d, detail=detail)
|
|
|
|
count = int(result.get("set") or 0)
|
|
if count <= 0:
|
|
return SessionImportResult(outcome="no_session", domain=d, detail="nothing applied")
|
|
logger.info(f"[session-import] applied {count} entries for {d}")
|
|
return SessionImportResult(outcome="imported", domain=d, entries_applied=count)
|