[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)

This commit is contained in:
ciregenz
2026-08-17 08:25:18 -07:00
parent a3f334fee9
commit 8185e3b52a
3 changed files with 173 additions and 0 deletions
@@ -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):
@@ -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
@@ -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