From 514aef45d62aa8a7cc22e6c73b88bcc5c75fa833 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 27 Jul 2026 14:01:05 -0700 Subject: [PATCH] [eric] browser: borrow the user's existing browser sign-in at a login wall (opt-in) --- backend/apps/agents/browser/browser_agent.py | 50 ++- .../agents/browser/browser_session_import.py | 153 +++++++++ backend/apps/onboarding/usage/__init__.py | 0 .../apps/onboarding/usage/browser_cookies.py | 316 ++++++++++++++++++ backend/apps/settings/models.py | 4 + backend/tests/test_browser_session_import.py | 188 +++++++++++ electron/main.js | 41 +++ electron/preload.js | 2 + .../sections/general/DataPrivacySection.tsx | 23 +- .../Settings/sections/general/GeneralTab.tsx | 2 +- frontend/src/shared/browserCommandHandler.ts | 21 ++ frontend/src/shared/state/settingsSlice.ts | 2 + 12 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 backend/apps/agents/browser/browser_session_import.py create mode 100644 backend/apps/onboarding/usage/__init__.py create mode 100644 backend/apps/onboarding/usage/browser_cookies.py create mode 100644 backend/tests/test_browser_session_import.py diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index a15194f7..c072b68e 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -65,6 +65,7 @@ from backend.apps.agents.browser import browser_metrics from backend.apps.agents.browser import browser_send_script from backend.apps.agents.browser import browser_send_parse from backend.apps.agents.browser import browser_login_handoff +from backend.apps.agents.browser import browser_session_import from backend.apps.agents.browser import browser_delivery_check from backend.apps.agents.browser import browser_submit_click from backend.apps.agents.browser import browser_playbook @@ -794,6 +795,36 @@ async def p_request_browser_approval( return decision +async def try_borrow_signin(domain: str, browser_id: str, tab_id: str, url: str) -> bool: + """Sign in by borrowing the session the user's everyday browser already holds, rather than + interrupting them to type a password we would rather never see. + + True only when the partition genuinely carries their session now, so a False always falls back + to the pause that existed before this did. Off unless the user opted in. + + Swallows everything: this is a convenience bolted onto the critical path, and the worst it may + ever cost is the pause we were going to show anyway. Cancellation still propagates (it is a + BaseException), so a stopped run still stops.""" + from backend.apps.settings.settings import load_settings + + try: + if not browser_session_import.is_enabled(load_settings()): + return False + if not browser_session_import.has_importable_session(domain): + return False + result = await browser_session_import.import_signin(domain, browser_id) + if not result.ok: + return False + # A borrowed session only takes on the next load, so send the page back through the door. + if url: + await execute_browser_tool("BrowserNavigate", {"url": url}, browser_id, tab_id) + except Exception as exc: + logger.info(f"[session-import] borrow skipped for {domain}: {type(exc).__name__}") + return False + logger.info(f"[session-import] continued on {domain} without interrupting the user") + return True + + # Background learning tasks (playbook distill) held by strong ref; asyncio only weak-refs tasks, and a GC'd task dies silently mid-distill. learn_tasks: set[asyncio.Task] = set() @@ -1664,13 +1695,18 @@ async def run_browser_agent( last_seen_url, "\n".join(attached_state_seen), allow_soft=(turn >= 2)) if p_wall_dom and p_wall_dom not in p_login_prompted: p_login_prompted.add(p_wall_dom) - p_login_problem, p_login_instruction = browser_login_handoff.prompt_copy(p_wall_dom) - p_login_decision = await p_request_browser_approval( - session, "RequestHumanIntervention", - {"problem": p_login_problem, "instruction": p_login_instruction}) - if cancel_event.is_set(): - break - if p_login_decision.get("behavior") != "deny": + # Borrow the sign-in the user already has in their everyday browser first: when it + # lands nobody is interrupted at all. Anything less falls through to the pause. + p_signed_in = await try_borrow_signin(p_wall_dom, browser_id, tab_id, last_seen_url) + if not p_signed_in: + p_login_problem, p_login_instruction = browser_login_handoff.prompt_copy(p_wall_dom) + p_login_decision = await p_request_browser_approval( + session, "RequestHumanIntervention", + {"problem": p_login_problem, "instruction": p_login_instruction}) + if cancel_event.is_set(): + break + p_signed_in = p_login_decision.get("behavior") != "deny" + if p_signed_in: browser_login_handoff.record_login(p_wall_dom) p_signed_note = (f"You are now signed in to {p_wall_dom}. The page has changed; " "look at it fresh and continue the task.") diff --git a/backend/apps/agents/browser/browser_session_import.py b/backend/apps/agents/browser/browser_session_import.py new file mode 100644 index 00000000..1005a6bd --- /dev/null +++ b/backend/apps/agents/browser/browser_session_import.py @@ -0,0 +1,153 @@ +"""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 + + +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] + + +@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) diff --git a/backend/apps/onboarding/usage/__init__.py b/backend/apps/onboarding/usage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/onboarding/usage/browser_cookies.py b/backend/apps/onboarding/usage/browser_cookies.py new file mode 100644 index 00000000..b61eb97f --- /dev/null +++ b/backend/apps/onboarding/usage/browser_cookies.py @@ -0,0 +1,316 @@ +"""Read the user's own logged-in provider cookies from their real browser, so +onboarding can harvest their actual chat history at first run without an in-app login. + +Chromium (Chrome/Arc/Brave/Edge) on macOS AND Windows. We first find WHICH store holds the +session by counting cookie names in the SQLite (no decryption, no keychain/DPAPI), then decrypt +only that one store, so the secret key is fetched at most once per browser (cached for the +process). Per-OS decryption: + - macOS: "Safe Storage" keychain password -> PBKDF2 -> AES-CBC (v10/v11). + - Windows: DPAPI-unwrapped key from Local State -> AES-256-GCM (v10/v11). +v20 = app-bound encryption (modern Chrome), out of reach on both without the browser's own +elevation service. Fails open to {} on anything (no browser, app-bound cookies, denied +keychain/DPAPI, Safari-only user), so prep just falls back to the local scan. + +Only ever reads the specific provider domain asked for; never a general cookie sweep. +The values are session secrets: used in-process for the harvest, never logged or stored. + +NOTE: the Windows path is written to the well-documented Chromium/DPAPI scheme but is NOT +live-tested from this repo's dev machine (macOS); the macOS path is live-proven (490 real +Claude convos). Both fail open, so a Windows decryption miss degrades to the scan, never crashes. +""" + +import base64 +import hashlib +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +from typing import Any, Dict, List, Optional, Tuple + +from typeguard import typechecked + +IS_WIN = sys.platform == "win32" + +# Per-OS "User Data" roots (relative to the home dir), where profiles + Local State live. +if IS_WIN: + p_local = os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")) + CHROMIUM_ROOTS = { + "Chrome": os.path.join(p_local, "Google", "Chrome", "User Data"), + "Arc": os.path.join(p_local, "Packages"), # Arc/Windows is UWP-packaged + rare; best-effort + "Brave": os.path.join(p_local, "BraveSoftware", "Brave-Browser", "User Data"), + "Edge": os.path.join(p_local, "Microsoft", "Edge", "User Data"), + } +else: + p_home = os.path.expanduser("~") + CHROMIUM_ROOTS = { + "Chrome": os.path.join(p_home, "Library/Application Support/Google/Chrome"), + "Arc": os.path.join(p_home, "Library/Application Support/Arc/User Data"), + "Brave": os.path.join(p_home, "Library/Application Support/BraveSoftware/Brave-Browser"), + "Edge": os.path.join(p_home, "Library/Application Support/Microsoft Edge"), + } +KEYCHAIN_SERVICE = { + "Chrome": "Chrome Safe Storage", + "Arc": "Arc Safe Storage", + "Brave": "Brave Safe Storage", + "Edge": "Microsoft Edge Safe Storage", +} +PROFILES = ["Default"] + [f"Profile {i}" for i in range(1, 12)] + +# One key fetch per browser per process; "Always Allow" (mac) / DPAPI (win) then never re-prompts. +p_key_cache: Dict[str, Optional[bytes]] = {} + + +@typechecked +def p_win_dpapi_unprotect(data: bytes) -> Optional[bytes]: + """CryptUnprotectData via crypt32.dll (no pywin32 dependency). None on any failure.""" + try: + import ctypes + from ctypes import wintypes + + class DATA_BLOB(ctypes.Structure): + p_fields = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] + _fields_ = p_fields + + buf = ctypes.create_string_buffer(data, len(data)) + blob_in = DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) + blob_out = DATA_BLOB() + ok = ctypes.windll.crypt32.CryptUnprotectData( + ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out) + ) + if not ok: + return None + n = int(blob_out.cbData) + out = ctypes.create_string_buffer(n) + ctypes.memmove(out, blob_out.pbData, n) + ctypes.windll.kernel32.LocalFree(blob_out.pbData) + return out.raw + except Exception: + return None + + +@typechecked +def p_win_storage_key(browser: str) -> Optional[bytes]: + """The AES key from a Chromium install's Local State: base64 -> strip 'DPAPI' -> CryptUnprotectData.""" + base = CHROMIUM_ROOTS.get(browser) + if not base: + return None + local_state = os.path.join(base, "Local State") + try: + with open(local_state, "r", encoding="utf-8") as f: + enc_b64 = json.load(f)["os_crypt"]["encrypted_key"] + raw = base64.b64decode(enc_b64) + if raw[:5] != b"DPAPI": + return None + return p_win_dpapi_unprotect(raw[5:]) + except Exception: + return None + + +@typechecked +def p_mac_storage_key(browser: str) -> Optional[bytes]: + try: + r = subprocess.run( + ["security", "find-generic-password", "-w", "-s", KEYCHAIN_SERVICE[browser]], + capture_output=True, text=True, timeout=20, + ) + pw = r.stdout.strip() + if pw: + return hashlib.pbkdf2_hmac("sha1", pw.encode(), b"saltysalt", 1003, 16) + except Exception: + pass + return None + + +@typechecked +def p_safe_storage_key(browser: str) -> Optional[bytes]: + if browser in p_key_cache: + return p_key_cache[browser] + key = p_win_storage_key(browser) if IS_WIN else p_mac_storage_key(browser) + p_key_cache[browser] = key + return key + + +@typechecked +def p_count_domain(db_path: str, domain: str) -> int: + tmp = tempfile.mktemp() + try: + shutil.copy2(db_path, tmp) + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + cur = con.cursor() + cur.execute("SELECT count(*) FROM cookies WHERE host_key LIKE ?", (f"%{domain}",)) + n = int(cur.fetchone()[0]) + con.close() + return n + except Exception: + return 0 + finally: + try: + os.remove(tmp) + except OSError: + pass + + +@typechecked +def p_best_store(domain: str) -> Optional[Tuple[str, str]]: + """The (browser, db_path) holding the most cookies for `domain`, found WITHOUT the keychain.""" + best: Optional[Tuple[str, str]] = None + best_score = (0, -1.0) + for browser, base in CHROMIUM_ROOTS.items(): + if not os.path.isdir(base): + continue + for prof in PROFILES: + for sub in ("Cookies", "Network/Cookies"): + path = os.path.join(base, prof, sub) + if not os.path.isfile(path): + continue + n = p_count_domain(path, domain) + if n: + score = (n, os.path.getmtime(path)) + if score > best_score: + best, best_score = (browser, path), score + return best + + +@typechecked +def p_decrypt(enc: bytes, key: bytes) -> Optional[str]: + if enc[:3] not in (b"v10", b"v11"): + return None # v20 = app-bound encryption, out of reach without the browser + try: + if IS_WIN: + # Windows Chromium: v10/v11 = AES-256-GCM, [3:15]=nonce, tail 16 bytes=tag (bundled with ct). + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + dec = AESGCM(key).decrypt(enc[3:15], enc[15:], None) + else: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + c = Cipher(algorithms.AES(key), modes.CBC(b" " * 16), backend=default_backend()) + d = c.decryptor() + dec = d.update(enc[3:]) + d.finalize() + dec = dec[: -dec[-1]] # strip PKCS7 padding + for cut in (0, 32): # newer Chromium prepends a 32-byte domain hash + try: + return dec[cut:].decode("utf-8") + except UnicodeDecodeError: + continue + except Exception: + return None + return None + + +@typechecked +def has_store(domain: str) -> bool: + """Whether any browser store holds records for `domain`, without decrypting and without touching the keychain. The public shape of the presence check, so callers outside this file never need the store tuple.""" + return p_best_store(domain) is not None + + +@typechecked +def read_provider_cookies(domain: str) -> Dict[str, str]: + """Decrypted cookie jar for `domain`, from whichever browser store actually has the session. At most one keychain touch (that store's browser), cached for the process.""" + store = p_best_store(domain) + if store is None: + return {} + browser, db_path = store + key = p_safe_storage_key(browser) + if key is None: + return {} + jar: Dict[str, str] = {} + tmp = tempfile.mktemp() + try: + shutil.copy2(db_path, tmp) + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + cur = con.cursor() + cur.execute("SELECT name, encrypted_value FROM cookies WHERE host_key LIKE ?", (f"%{domain}",)) + for name, enc in cur.fetchall(): + if not enc: + continue + val = p_decrypt(bytes(enc), key) + if val: + jar[str(name)] = val + con.close() + except Exception: + pass + finally: + try: + os.remove(tmp) + except OSError: + pass + return jar + + +@typechecked +def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]: + """Full cookie records ({name,value,domain,path,secure,httponly,expires_utc}) for `domain`, so Electron's offscreen browser can re-inject the session faithfully and pass Cloudflare with a real Chrome TLS handshake. Same one-store, one-keychain-touch path as read_provider_cookies. `expires_utc` stays in Chromium's own units (microseconds since 1601, 0 = session cookie); whoever needs unix seconds converts.""" + store = p_best_store(domain) + if store is None: + return [] + browser, db_path = store + key = p_safe_storage_key(browser) + if key is None: + return [] + records: List[Dict[str, Any]] = [] + tmp = tempfile.mktemp() + try: + shutil.copy2(db_path, tmp) + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + cur = con.cursor() + cur.execute( + "SELECT name, encrypted_value, host_key, path, is_secure, is_httponly, expires_utc " + "FROM cookies WHERE host_key LIKE ?", + (f"%{domain}",), + ) + for name, enc, host_key, path, is_secure, is_httponly, expires_utc in cur.fetchall(): + if not enc: + continue + val = p_decrypt(bytes(enc), key) + if val is None: + continue + records.append({ + "name": str(name), "value": val, "domain": str(host_key), + "path": str(path) or "/", "secure": bool(is_secure), "httponly": bool(is_httponly), + "expires_utc": int(expires_utc or 0), + }) + con.close() + except Exception: + pass + finally: + try: + os.remove(tmp) + except OSError: + pass + return records + + +# Gemini authenticates on the parent .google.com SSO domain, not gemini.google.com, so its +# session lives in these named cookies. We read ONLY these (never the whole google cookie +# jar) and only to load a Gemini page offscreen, keeping the ChatGPT/Claude trust frame. +GOOGLE_AUTH_COOKIE_NAMES = { + "SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "NID", + "__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDTS", "__Secure-3PSIDTS", + "__Secure-1PSIDCC", "__Secure-3PSIDCC", "__Secure-1PAPISID", "__Secure-3PAPISID", +} + + +@typechecked +def read_google_session_records() -> List[Dict[str, Any]]: + """The named Google SSO cookies from .google.com, so the offscreen browser can load Gemini logged in. Scoped to the auth set by name, never a general google-cookie sweep.""" + return [r for r in read_provider_cookie_records(".google.com") if r.get("name") in GOOGLE_AUTH_COOKIE_NAMES] + + +@typechecked +def cookie_header(jar: Dict[str, str]) -> str: + return "; ".join(f"{k}={v}" for k, v in jar.items()) + + +@typechecked +def logged_in_providers() -> List[str]: + """Which providers have a readable session, WITHOUT decrypting or touching the keychain: safe for a UI presence check.""" + out: List[str] = [] + for provider, domain in (("codex", "chatgpt.com"), ("claude", "claude.ai"), ("gemini", "gemini.google.com")): + if p_best_store(domain) is not None: + out.append(provider) + return out diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 8714cc53..dd27dbb3 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -45,6 +45,10 @@ class AppSettings(BaseModel): new_agent_shortcut: str = "Meta+l" anthropic_api_key: Optional[str] = None browser_homepage: str = "https://www.google.com" + # Opt-in: let a blocked browser agent borrow the sign-in you already have in your everyday + # browser instead of stopping to ask you to log in again. Default OFF because reading your real + # browser's session is your decision to make once, explicitly, not ours to assume. + browser_import_signins: bool = False openai_api_key: Optional[str] = None google_api_key: Optional[str] = None openrouter_api_key: Optional[str] = None diff --git a/backend/tests/test_browser_session_import.py b/backend/tests/test_browser_session_import.py new file mode 100644 index 00000000..3d45f70d --- /dev/null +++ b/backend/tests/test_browser_session_import.py @@ -0,0 +1,188 @@ +"""Borrowing the user's existing sign-in instead of interrupting them for a password. + +This module reads the user's real browser, so the tests are mostly about what it must REFUSE to do. +Nothing here touches a real store or a keychain: the reader is stubbed at every call site. +""" +import os +import re + +import pytest + +from backend.apps.agents.browser import browser_session_import as si +from backend.apps.settings.models import AppSettings + +P_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +P_MAIN_JS = os.path.join(P_REPO_ROOT, "electron", "main.js") + +RECORDS = [{"name": "sid", "value": "opaque", "domain": ".x.com", "path": "/", + "secure": True, "httponly": True, "expires": 1900000000.0}] + + +def test_opt_in_is_off_by_default(): + """Reading someone's real browser is their call to make explicitly. If this ever defaults True, + an upgrade would silently start reading stores the user never agreed to expose.""" + assert si.is_enabled(AppSettings()) is False + + +def test_opt_in_flips(): + s = AppSettings() + s.browser_import_signins = True + assert si.is_enabled(s) is True + + +def test_google_properties_route_to_the_sso_scope(): + """A Gmail/YouTube session lives on the parent SSO domain, not the property's own host, and the + reader has a NAMED scope for it. Getting this wrong means either no session at all or a general + sweep of every google entry the user owns.""" + for d in ("mail.google.com", "google.com", "docs.google.com", "youtube.com", "www.youtube.com"): + assert si.is_google_property(d), d + for d in ("reddit.com", "x.com", "notgoogle.com", "google.com.evil.net", ""): + assert not si.is_google_property(d), d + + +def test_domain_normalisation_matches_the_handoff(): + """One definition of 'which site is this', shared with the login handoff, or the two can + disagree about which domain we just borrowed for.""" + assert si.site_domain("https://www.reddit.com/submit?x=1") == "reddit.com" + assert si.site_domain("x.com") == "x.com" + assert si.site_domain("") == "" + + +@pytest.mark.asyncio +async def test_no_session_never_wakes_the_bridge(monkeypatch): + """Nothing to import means nothing to send. Calling the renderer with an empty payload would + burn a round trip and log a bogus failure.""" + called = [] + monkeypatch.setattr(si, "read_site_records", lambda d: []) + monkeypatch.setattr(si.ws_manager, "send_browser_command", + lambda *a, **k: called.append(a) or {}) + result = await si.import_signin("x.com", "b1") + assert result.outcome == "no_session" + assert result.ok is False + assert called == [] + + +@pytest.mark.asyncio +async def test_empty_domain_reads_nothing(monkeypatch): + """A blank URL must not turn into a wildcard read.""" + monkeypatch.setattr(si, "read_site_records", + lambda d: pytest.fail("must not read for an empty domain")) + assert (await si.import_signin("", "b1")).outcome == "no_session" + + +@pytest.mark.asyncio +async def test_successful_import_reports_what_landed(monkeypatch): + async def fake_send(rid, action, browser_id, params, **kw): + assert action == "import_session" + assert params["domain"] == "x.com" + assert params["cookies"] == RECORDS + return {"ok": True, "set": 1, "total": 1} + + monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) + monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) + result = await si.import_signin("https://x.com/compose/post", "b1") + assert result.outcome == "imported" + assert result.ok is True + assert result.entries_applied == 1 + assert result.domain == "x.com" + + +@pytest.mark.asyncio +async def test_bridge_error_is_a_result_not_an_exception(monkeypatch): + """Every failure has to degrade into something the caller can fall back from, because the + fallback (ask the user to sign in) is the behaviour that existed before this did.""" + async def fake_send(*a, **k): + return {"error": "No dashboard is connected."} + + monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) + monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) + result = await si.import_signin("x.com", "b1") + assert result.outcome == "bridge_failed" + assert result.ok is False + + +@pytest.mark.asyncio +async def test_applied_nothing_is_not_success(monkeypatch): + """The bridge answering 'ok' while applying zero entries must NOT read as signed in, or the run + skips the pause and then fails on a page it still cannot use.""" + async def fake_send(*a, **k): + return {"ok": True, "set": 0, "total": 4} + + monkeypatch.setattr(si, "read_site_records", lambda d: list(RECORDS)) + monkeypatch.setattr(si.ws_manager, "send_browser_command", fake_send) + assert (await si.import_signin("x.com", "b1")).ok is False + + +def test_expiry_is_translated_out_of_chromium_time(monkeypatch): + """Chromium counts microseconds from 1601; Electron wants unix seconds. Get this wrong and every + borrowed entry is either already expired or session-scoped, so the sign-in dies on the next quit + and the user quietly stops believing the feature works.""" + monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", + lambda d: [{"name": "sid", "value": "opaque", "expires_utc": 13400000000000000}, + {"name": "tmp", "value": "opaque", "expires_utc": 0}]) + out = si.read_site_records("x.com") + assert out[0]["expires"] == pytest.approx(1755526400.0) + assert out[1]["expires"] == 0.0, "a session entry must stay session-scoped, not become 1601" + + +def test_google_reads_go_through_the_named_sso_scope(monkeypatch): + """A Gmail borrow must use the reader's named SSO set, never a general sweep of the user's + google entries.""" + monkeypatch.setattr(si.browser_cookies, "read_google_session_records", + lambda: [{"name": "SID", "value": "opaque", "expires_utc": 0}]) + monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", + lambda d: pytest.fail("google must not go through the generic read")) + assert [r["name"] for r in si.read_site_records("mail.google.com")] == ["SID"] + + +def test_unreadable_browser_degrades_instead_of_crashing(monkeypatch): + """A locked keychain, a v20 app-bound store, a browser that isn't installed: all of it is a + fallback, never an exception that kills the run.""" + def boom(d): + raise RuntimeError("read denied") + + monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", boom) + assert si.read_site_records("x.com") == [] + + monkeypatch.setattr(si.browser_cookies, "p_best_store", boom) + assert si.has_importable_session("x.com") is False + + +@pytest.mark.asyncio +async def test_a_broken_borrow_can_never_break_the_run(monkeypatch): + """The class this seals: borrowing is a convenience bolted onto the critical path, so ANY + failure inside it must cost at most the pause we were going to show anyway. Caught for real by + the suite, where a loose settings double made the helper raise and killed the whole browser run + before it could even reach the sign-in prompt.""" + from backend.apps.agents.browser import browser_agent + + def boom(*a, **k): + raise TypeError("settings double is not the real thing") + + monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", boom) + assert await browser_agent.try_borrow_signin("acme.example", "b1", "", "") is False + + +def test_agent_checks_the_opt_in_before_reading_anything(): + """INVARIANT: the borrow helper must consult the setting FIRST. Pinned by source because the + ordering is the whole consent story, and an innocent-looking reorder would start reading the + user's browser before asking whether they wanted that.""" + import inspect + + from backend.apps.agents.browser import browser_agent + + src = inspect.getsource(browser_agent.try_borrow_signin) + gate = src.index("is_enabled") + assert gate < src.index("has_importable_session"), "opt-in must be checked before probing" + assert gate < src.index("import_signin"), "opt-in must be checked before importing" + + +def test_partition_write_confines_entries_to_the_requested_domain(): + """INVARIANT on the Electron side: importing one site must never plant another site's session + in the partition. Pinned by source since main.js needs a live Electron to execute.""" + with open(P_MAIN_JS, encoding="utf-8") as fh: + src = fh.read() + body = src[src.index("async function writePartitionCookies"):] + body = body[:body.index("ipcMain.handle('set-partition-cookies'")] + assert re.search(r"if \(host !== d && !host\.endsWith\(`\.\$\{d\}`\)\) continue;", body), \ + "the per-entry domain confinement guard is gone from writePartitionCookies" diff --git a/electron/main.js b/electron/main.js index 7b462187..783c52eb 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2797,6 +2797,47 @@ async function readPartitionCookies(domain) { } ipcMain.handle('get-partition-cookies', (_e, domain) => readPartitionCookies(domain)); +// Populate the browser-card partition with the user's OWN existing sign-in for a site, so an agent +// stuck at a login wall can carry on as them without anybody typing a password. This is the exact +// opposite direction from the read above: cookies only go INTO our own partition, never out, so it +// is not a disclosure surface. The backend gates it behind an explicit opt-in setting and always +// derives the domain from the page the agent is already stuck on, never from model text. +async function writePartitionCookies(domain, cookies) { + const d = String(domain || '').toLowerCase().trim().replace(/^\./, ''); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(d)) return { ok: false, set: 0, error: `bad domain: ${d || '(empty)'}` }; + const list = Array.isArray(cookies) ? cookies : []; + const ses = session.fromPartition(BROWSER_PARTITION); + let set = 0; + for (const c of list) { + if (!c || !c.name) continue; + const rawHost = String(c.domain || d); + const host = rawHost.replace(/^\./, ''); + // Every cookie has to belong to the domain we were asked for, so importing one site can never + // plant another site's session in the partition. + if (host !== d && !host.endsWith(`.${d}`)) continue; + const path = String(c.path || '/') || '/'; + try { + await ses.cookies.set({ + url: `https://${host}${path.startsWith('/') ? path : `/${path}`}`, + name: String(c.name), + value: String(c.value == null ? '' : c.value), + // A leading dot is Chromium's marker for a domain-wide cookie; without it the cookie is + // host-only and passing `domain` at all would silently widen it. + domain: rawHost.startsWith('.') ? rawHost : undefined, + path, + secure: !!c.secure, + httpOnly: !!c.httponly, + expirationDate: Number(c.expires) > 0 ? Number(c.expires) : undefined, + }); + set += 1; + } catch (err) { + // One malformed cookie must not sink the whole sign-in. + } + } + return { ok: set > 0, set, total: list.length }; +} +ipcMain.handle('set-partition-cookies', (_e, domain, cookies) => writePartitionCookies(domain, cookies)); + // The renderer relays cookie reads for the session-borrow bridge, but macOS throttles it when the // window is backgrounded, so those reads intermittently time out. Main never throttles: hold our own // socket to the backend and answer get_session_cookies here. Cookie reads only; the renderer still diff --git a/electron/preload.js b/electron/preload.js index 9c3854fb..3f06f37a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -62,6 +62,8 @@ contextBridge.exposeInMainWorld('openswarm', { connectSlack: () => ipcRenderer.invoke('connect-slack'), // Hands a vetted social platform's partition cookies to its session-backed MCP shim (allowlisted domains only, gated again in the main process). getPartitionCookies: (domain) => ipcRenderer.invoke('get-partition-cookies', domain), + // Loads the user's own existing sign-in for a site INTO the browser partition so a blocked agent can continue as them. Writes only, never reads back; main re-checks every cookie belongs to the domain asked for. + setPartitionCookies: (domain, cookies) => ipcRenderer.invoke('set-partition-cookies', domain, cookies), sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId), cdpDetachClean: (wcId) => ipcRenderer.invoke('cdp-detach-clean', wcId), cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap), diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx index 1186b0d4..99ad2355 100644 --- a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -3,7 +3,9 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; +import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; +import type { AppSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { API_BASE } from '@/shared/config'; import type { SettingsStyles } from '../settingsStyles'; @@ -11,7 +13,11 @@ import type { SettingsStyles } from '../settingsStyles'; const ERASE_WORD = 'ERASE'; // The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the destructive label, and the real friction is the typed-confirm in the dialog. -const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => { +const DataPrivacySection: React.FC<{ + form: AppSettings; + setForm: React.Dispatch>; + styles: SettingsStyles; +}> = ({ form, setForm, styles }) => { const c = useClaudeTokens(); const { sectionSx, labelSx, descSx } = styles; @@ -122,6 +128,21 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => + + + Use my sign-ins from my other browser + When an agent hits a site you're not signed into here, borrow the sign-in you already have in Chrome, Arc, Brave, or Edge instead of stopping to ask you. Reads only the site it's stuck on, and never asks for a password. Off by default. + + setForm({ ...form, browser_import_signins: e.target.checked })} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary }, + }} + /> + + Clear browsing data diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx index 4640c64e..be4f9ed3 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralTab.tsx @@ -43,7 +43,7 @@ const GeneralTab: React.FC<{ - + ); diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index dbbe545e..2b7f286a 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -1867,6 +1867,22 @@ async function handleSessionCookies(params: Record): Promise): Promise> { + const bridge = (window as any).openswarm?.setPartitionCookies as + | ((domain: string, cookies: Record[]) => Promise<{ ok: boolean; set: number; error?: string }>) + | undefined; + if (!bridge) return { ok: false, set: 0, error: 'Session import unavailable (desktop app only)' }; + const cookies = Array.isArray(params.cookies) ? params.cookies : []; + try { + return await bridge(String(params.domain || ''), cookies); + } catch (err: any) { + return { ok: false, set: 0, error: `Session import failed: ${err?.message || String(err)}` }; + } +} + // Drive a session-borrow site's own already-open card: resolve the webview by its live domain // (no browser_id, like the cookie bridge), then run a small navigate/evaluate step sequence. // The shims use this for writes on sites that sign every HTTP request (TikTok). @@ -1905,6 +1921,11 @@ async function runBrowserCommand( dashboardWs.send('browser:result', { request_id, ...result }); return; } + if (action === 'import_session') { + const result = await handleImportSession(params); + dashboardWs.send('browser:result', { request_id, ...result }); + return; + } const wv = await awaitWebview(browser_id, tab_id || undefined); if (!wv) { dashboardWs.send('browser:result', { diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index c9a8fec5..43a870c6 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -51,6 +51,7 @@ export interface AppSettings { openrouter_api_key?: string | null; custom_providers?: CustomProvider[]; browser_homepage: string; + browser_import_signins: boolean; auto_select_mode_on_new_agent: boolean; expand_new_chats_in_dashboard: boolean; auto_reveal_sub_agents: boolean; @@ -127,6 +128,7 @@ export const DEFAULT_SETTINGS: AppSettings = { new_agent_shortcut: 'Meta+l', anthropic_api_key: null, browser_homepage: 'https://duckduckgo.com', + browser_import_signins: false, auto_select_mode_on_new_agent: false, expand_new_chats_in_dashboard: true, auto_reveal_sub_agents: true,