mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 13:02:23 +02:00
[eric] browser: borrow the user's existing browser sign-in at a login wall (opt-in)
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user