mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
279 lines
14 KiB
Python
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"
|