[eric] browser: borrow the user's existing browser sign-in at a login wall (opt-in)

This commit is contained in:
ciregenz
2026-07-27 14:01:05 -07:00
parent 404ca418a3
commit 514aef45d6
12 changed files with 793 additions and 9 deletions
+43 -7
View File
@@ -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.")
@@ -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)
@@ -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
+4
View File
@@ -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
@@ -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"
+41
View File
@@ -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
+2
View File
@@ -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),
@@ -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<React.SetStateAction<AppSettings>>;
styles: SettingsStyles;
}> = ({ form, setForm, styles }) => {
const c = useClaudeTokens();
const { sectionSx, labelSx, descSx } = styles;
@@ -122,6 +128,21 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
<Button variant="outlined" size="small" onClick={() => { setErr(null); setResetOpen(true); }} sx={rowBtnSx}>Reset</Button>
</Box>
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Use my sign-ins from my other browser</Typography>
<Typography sx={descSx}>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.</Typography>
</Box>
<Switch
checked={form.browser_import_signins}
onChange={(e) => 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 },
}}
/>
</Box>
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box>
<Typography sx={labelSx}>Clear browsing data</Typography>
@@ -43,7 +43,7 @@ const GeneralTab: React.FC<{
<GeneralAdvanced form={form} setForm={setForm} styles={styles} />
<DataPrivacySection styles={styles} />
<DataPrivacySection form={form} setForm={setForm} styles={styles} />
</Box>
);
@@ -1867,6 +1867,22 @@ async function handleSessionCookies(params: Record<string, any>): Promise<Record
}
}
// Load the user's own existing sign-in for a site into the browser partition, so an agent stuck at
// a login wall can carry on as them instead of interrupting to ask for a password. Like the cookie
// bridge above this needs no webview: it writes straight to the main-process cookie store.
async function handleImportSession(params: Record<string, any>): Promise<Record<string, any>> {
const bridge = (window as any).openswarm?.setPartitionCookies as
| ((domain: string, cookies: Record<string, any>[]) => 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', {
@@ -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,