Files
openswarm/backend/tests/test_browser_session_import.py

279 lines
14 KiB
Python

"""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_the_whole_jar_travels_including_clearance_tokens(monkeypatch):
"""A real browser sends everything it has, so we do too.
An earlier version held back the anti-bot clearance tokens (cf_clearance and friends) on the
theory that ours could never match the user agent they were minted for. That was measured live
on medium with the UA swap CONFIRMED firing in the Electron log, and it changed nothing in
either direction, so the filter was carrying a story rather than its weight. Holding a cookie
back is a claim about the site's auth that we could not support."""
monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", lambda d: [
{"name": "sid", "value": "opaque", "expires_utc": 0},
{"name": "uid", "value": "opaque", "expires_utc": 0},
{"name": "cf_clearance", "value": "opaque", "expires_utc": 0},
{"name": "__cf_bm", "value": "opaque", "expires_utc": 0},
])
assert sorted(r["name"] for r in si.read_site_records("medium.com")) == [
"__cf_bm", "cf_clearance", "sid", "uid"]
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
@pytest.mark.asyncio
async def test_borrow_happens_at_the_door_not_only_at_the_wall(monkeypatch):
"""Borrowing only at a detected wall was too late: a task the model answers in one turn calls
Done, which breaks the loop BEFORE the handoff runs, so short tasks never got the session at
all. Navigating must carry it."""
from backend.apps.agents.browser import browser_agent
seen = []
browser_agent.signin_borrowed.discard("x.com")
monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: True)
monkeypatch.setattr(browser_agent.browser_session_import, "has_importable_session", lambda d: True)
async def fake_import(domain, browser_id):
seen.append(domain)
return si.SessionImportResult(outcome="imported", domain=domain, entries_applied=3)
monkeypatch.setattr(browser_agent.browser_session_import, "import_signin", fake_import)
await browser_agent.borrow_signin_before_nav("https://x.com/compose/post", "b1")
assert seen == ["x.com"], "navigating to a site must borrow its sign-in first"
# Second navigate to the same site must not re-read the user's browser.
await browser_agent.borrow_signin_before_nav("https://x.com/home", "b1")
assert seen == ["x.com"], "a borrowed site must not be re-imported on every navigate"
browser_agent.signin_borrowed.discard("x.com")
@pytest.mark.asyncio
async def test_pre_nav_borrow_respects_the_opt_in(monkeypatch):
"""The door is the busiest path in the whole agent, so the gate has to hold there too."""
from backend.apps.agents.browser import browser_agent
browser_agent.signin_borrowed.discard("x.com")
monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: False)
monkeypatch.setattr(browser_agent.browser_session_import, "has_importable_session",
lambda d: pytest.fail("must not probe the user's browser while opted out"))
await browser_agent.borrow_signin_before_nav("https://x.com/home", "b1")
@pytest.mark.asyncio
async def test_wall_handoff_asks_a_human_once_the_door_borrow_did_not_take(monkeypatch):
"""If we already borrowed at the door and are STILL at a wall, the session did not work.
Re-importing identical values would change nothing, so this case belongs to the human, and
silently returning True here would skip the prompt and strand the run."""
from backend.apps.agents.browser import browser_agent
browser_agent.signin_borrowed.add("acme.example")
monkeypatch.setattr(browser_agent.browser_session_import, "is_enabled", lambda s: True)
monkeypatch.setattr(browser_agent.browser_session_import, "import_signin",
lambda d, b: pytest.fail("must not re-import the same values"))
try:
assert await browser_agent.try_borrow_signin("acme.example", "b1", "", "") is False
finally:
browser_agent.signin_borrowed.discard("acme.example")
def test_import_timeout_outlasts_the_hidden_window_warm():
"""INVARIANT, and the second time this exact trap has bitten (see find_composer): the warm sits
through the site's bot challenge inside the import command, so the command's timeout has to
outlast the warm's own budget. Set them past each other and the window is killed mid-challenge,
which throws away the entire reason the warm exists while still looking like a clean import."""
from backend.apps.agents.core.ws_manager import BROWSER_CMD_TIMEOUTS, BROWSER_CMD_TIMEOUT_DEFAULT
with open(os.path.join(P_REPO_ROOT, "electron", "warmBorrowedSession.js"), encoding="utf-8") as fh:
src = fh.read()
budget_ms = sum(int(m) for m in re.findall(
r"^const (?:LOAD_TIMEOUT_MS|SETTLE_MS|DESTROY_GRACE_MS) = (\d+);", src, re.M))
assert budget_ms > 0, "warm budget constants not found; did warmBorrowedSession change shape?"
timeout_s = BROWSER_CMD_TIMEOUTS.get("import_session", BROWSER_CMD_TIMEOUT_DEFAULT)
assert timeout_s * 1000 > budget_ms, (
f"import_session timeout {timeout_s}s must outlast the warm budget {budget_ms}ms")
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"