From 8185e3b52a24624bbcf3c9d0d1365d45c5586ab7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 17 Aug 2026 08:25:18 -0700 Subject: [PATCH] [eric] browser: a hard login wall with nothing to borrow hands off to the user at the card and resumes itself when the wall clears (ENG-279) --- backend/apps/agents/browser/browser_agent.py | 33 ++++++++ .../agents/browser/wait_for_user_signin.py | 62 +++++++++++++++ backend/tests/test_wait_for_user_signin.py | 78 +++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 backend/apps/agents/browser/wait_for_user_signin.py create mode 100644 backend/tests/test_wait_for_user_signin.py diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 079d38d7..b3f4ae0f 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -86,6 +86,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.wait_for_user_signin import wait_for_user_signin 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 @@ -1368,6 +1369,7 @@ async def run_browser_agent( card_gone_streak = 0 # consecutive "card is gone" results -> fail fast, don't spin route_hinted_hosts: set[str] = set() # surface the fast network tier once per host p_login_attempted: set[str] = set() # login-once handoff: at most one silent borrow attempt per domain per run + p_signin_wait_used = False # at most ONE user-signin pause per run, so stacked walls can't stall it for 3min each # Stagnation state: busy-but-stuck detection (no URL change + failures across a run of actions), distinct from the exact-repeat loop above. stagnation_streak = 0 @@ -1936,10 +1938,41 @@ 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_attempted: p_login_attempted.add(p_wall_dom) + p_signed_note = None if await try_borrow_signin(p_wall_dom, browser_id, tab_id, last_seen_url): 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.") + elif (not p_signin_wait_used and ws_manager.global_connections + and browser_login_handoff.login_wall_domain(last_seen_url, "\n".join(attached_state_seen))): + # No session to borrow and a HARD wall (soft signed-out pages stay browsable, no + # pause). The card is on the user's screen (a renderer is attached), so hand off: + # tell them, watch the page, resume the moment the wall clears (ENG-279). + p_signin_wait_used = True + p_wait_msg = Message(role="assistant", content=( + f"⏸ {p_wall_dom} needs you to sign in. Use the browser card directly; " + "I'll continue automatically once you're in (waiting up to 3 minutes).")) + session.messages.append(p_wait_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": p_wait_msg.model_dump(mode="json"), + }) + + async def p_signin_probe() -> tuple: + p_gt = await execute_browser_tool("BrowserGetText", {}, browser_id, tab_id) + if not isinstance(p_gt, dict): + return ("", "") + return (str(p_gt.get("url") or ""), str(p_gt.get("text") or "")) + + if await wait_for_user_signin(p_wall_dom, p_signin_probe, cancel_event): + browser_login_handoff.record_login(p_wall_dom) + p_signed_note = (f"The user just signed in to {p_wall_dom} themselves. The page " + "has changed; look at it fresh and continue the task.") + else: + p_signed_note = (f"The user did not sign in to {p_wall_dom} within the wait " + "window. Do what is possible without it, and say plainly in " + "Done what still needs the sign-in.") + if p_signed_note: if messages and messages[-1].get("role") == "user": p_prev = messages[-1]["content"] if isinstance(p_prev, list): diff --git a/backend/apps/agents/browser/wait_for_user_signin.py b/backend/apps/agents/browser/wait_for_user_signin.py new file mode 100644 index 00000000..986a168c --- /dev/null +++ b/backend/apps/agents/browser/wait_for_user_signin.py @@ -0,0 +1,62 @@ +"""A browser agent stuck on a hard login wall, with no session to borrow, used to just keep +failing at the wall; the one thing that fixes it is the human signing in, and the browser card is +RIGHT THERE to do it in. So: pause the model loop and watch the page until the wall clears, then +resume automatically (ENG-279). This must never fire RequestHumanIntervention (Eric's call, +2026-08-08: auto-injected interventions made workflows impossible to run unattended), which is why +it is a backend poll with a hard deadline, not a HITL tool: an unattended run stalls at most +MAX_WAIT_SECONDS and then continues with an honest note.""" + +import asyncio +import logging +from typing import Awaitable, Callable, Tuple + +from typeguard import typechecked + +from backend.apps.agents.browser import browser_login_handoff + +logger = logging.getLogger(__name__) + +POLL_SECONDS = 4.0 +MAX_WAIT_SECONDS = 180.0 +# One clear poll can be an OAuth redirect mid-flight (interstitial URL that matches no wall); +# require two in a row so the resume note only fires once the destination page is really back. +CLEAR_POLLS_NEEDED = 2 + + +@typechecked +async def wait_for_user_signin( + domain: str, + probe: Callable[[], Awaitable[Tuple[str, str]]], + cancel_event: asyncio.Event, +) -> bool: + """Poll the page until the login wall for `domain` clears. `probe` returns the page's current + (url, visible_text). True = the user signed in (two consecutive wall-free polls); False = + deadline, cancellation, or probe failure. Never raises.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + MAX_WAIT_SECONDS + clear_streak = 0 + while loop.time() < deadline: + if cancel_event.is_set(): + return False + try: + await asyncio.wait_for(cancel_event.wait(), timeout=POLL_SECONDS) + return False + except asyncio.TimeoutError: + pass + try: + url, text = await probe() + except Exception: + clear_streak = 0 + continue + if not url: + clear_streak = 0 + continue + if browser_login_handoff.login_wall_domain(url, text) is None: + clear_streak += 1 + if clear_streak >= CLEAR_POLLS_NEEDED: + logger.info(f"[signin-wait] wall on {domain} cleared by the user") + return True + else: + clear_streak = 0 + logger.info(f"[signin-wait] {domain} still walled after {int(MAX_WAIT_SECONDS)}s; continuing without") + return False diff --git a/backend/tests/test_wait_for_user_signin.py b/backend/tests/test_wait_for_user_signin.py new file mode 100644 index 00000000..19950463 --- /dev/null +++ b/backend/tests/test_wait_for_user_signin.py @@ -0,0 +1,78 @@ +"""The ENG-279 sign-in pause: a hard login wall with nothing to borrow hands off to the human at +the browser card and resumes itself. The dangerous edges are (1) resuming on an OAuth redirect +interstitial (one wall-free poll is not signed in), (2) never ending (deadline must hold), and +(3) outliving a Stop (cancel must win instantly).""" + +import asyncio + +import pytest + +from backend.apps.agents.browser import wait_for_user_signin as w + + +WALL = ("https://x.com/i/flow/login", "Sign in to X\nPassword") +CLEAR = ("https://x.com/home", "Home\nFor you\nFollowing\nPost") + + +def p_probe_from(seq): + it = iter(seq) + last = seq[-1] + + async def probe(): + return next(it, last) + return probe + + +@pytest.mark.asyncio +async def test_two_clear_polls_resume(monkeypatch): + monkeypatch.setattr(w, "POLL_SECONDS", 0.01) + monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 5.0) + ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL, WALL, CLEAR, CLEAR]), asyncio.Event()) + assert ok is True + + +@pytest.mark.asyncio +async def test_one_clear_poll_is_not_signed_in(monkeypatch): + # An OAuth redirect interstitial matches no wall for a beat; a single clear poll must not resume. + monkeypatch.setattr(w, "POLL_SECONDS", 0.01) + monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.3) + ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL, CLEAR, WALL, CLEAR, WALL]), asyncio.Event()) + assert ok is False, "alternating clear/wall must never satisfy the two-in-a-row rule" + + +@pytest.mark.asyncio +async def test_deadline_holds_when_user_never_signs_in(monkeypatch): + monkeypatch.setattr(w, "POLL_SECONDS", 0.01) + monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.15) + t0 = asyncio.get_running_loop().time() + ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL]), asyncio.Event()) + assert ok is False + assert asyncio.get_running_loop().time() - t0 < 2.0 + + +@pytest.mark.asyncio +async def test_cancel_wins_instantly(monkeypatch): + monkeypatch.setattr(w, "POLL_SECONDS", 30.0) + monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 300.0) + ev = asyncio.Event() + + async def cancel_soon(): + await asyncio.sleep(0.05) + ev.set() + task = asyncio.create_task(cancel_soon()) + t0 = asyncio.get_running_loop().time() + ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL]), ev) + await task + assert ok is False + assert asyncio.get_running_loop().time() - t0 < 2.0, "a Stop must not wait out the poll interval" + + +@pytest.mark.asyncio +async def test_probe_failure_reads_as_still_walled(monkeypatch): + monkeypatch.setattr(w, "POLL_SECONDS", 0.01) + monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.15) + + async def broken_probe(): + raise RuntimeError("webview gone") + ok = await w.wait_for_user_signin("x.com", broken_probe, asyncio.Event()) + assert ok is False