Revamp login guards into general per-firm page guards

Replace the single hardcoded signed-out check with a guards list on each
firm: any guard can trip mid-run via a URL substring (checked on every
step) or a selector probe (checked when a step fails). Each guard defines
recovery steps or a human-wait fallback, a per-run trigger cap, and a
resume policy — 'continue' retries the tripped step, 'restart' rewinds
the run to step 1.

Runner bumped to 0.20.0; the server still ships the legacy
signedOut/authSteps shape for stale runners, and a new runner synthesizes
a guard from a legacy payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-14 12:50:14 -05:00
co-authored by Claude Fable 5
parent 82b98b9d2d
commit b1dccd7dec
5 changed files with 371 additions and 164 deletions
+38 -19
View File
@@ -15,7 +15,8 @@ class StepError(Exception):
"""A step refused to run, or ran and could not be verified.
`code` mirrors the CLI's exit codes: 3 = refused before acting,
4 = acted but verification failed, 5 = the session is signed out.
4 = acted but verification failed, 5 = a page guard tripped (login
redirect, blocking modal, and the like).
"""
def __init__(self, message: str, code: int = 3):
@@ -23,33 +24,44 @@ class StepError(Exception):
self.code = code
class SignedOutError(StepError):
"""The tab is on the login page, so the session has expired.
class GuardTripped(StepError):
"""A page guard's condition is on the page — the run cannot proceed past it.
Distinct from an ordinary failure because nothing is wrong with the
automation — it just cannot proceed until a human signs in.
Distinct from an ordinary failure because nothing is wrong with the step:
the page has been taken over by something (a login redirect, an expired-
session modal) and every selector now resolves against the wrong document.
A click or a keystroke aimed at the old page would land on whatever the
guard page happens to render in the same place. The runner decides how to
recover; the CLI just stops with exit code 5.
"""
def __init__(self, url: str):
def __init__(self, guard: dict, url: str):
name = guard.get("label") or guard.get("id") or "page"
super().__init__(
f"signed out — the tab is on {url}. Log in to the site, then run this again.",
f"{name} guard tripped — the tab is on {url}. "
"Deal with the page, then run this again.",
code=5,
)
self.guard = guard
self.url = url
def check_signed_in(found: dict, signed_out: str) -> None:
"""Abort if the page redirected to a login screen.
def check_guards(found: dict, guards: list | None) -> None:
"""Abort if the page has tripped any URL guard.
Worth doing before every action, not just at the start: a session can expire
mid-run, and once it does, every selector resolves against a login form. A
click or a keystroke aimed at the old page would land on whatever that form
happens to render in the same place.
Worth doing before every action, not just at the start: a session can
expire (or a guard page appear) mid-run. Only URL guards are checked here —
the URL rides along with every locate answer, so they are free. Selector
guards cost a locate round trip each and are probed by the runner only once
a step has already failed.
"""
if not signed_out:
if not guards:
return
url = found.get("url") or ""
if signed_out in url:
raise SignedOutError(url)
for guard in guards:
pattern = guard.get("urlContains") or ""
if pattern and pattern in url:
raise GuardTripped(guard, url)
# Display scaling factors that actually exist. Anything else means the two sides
@@ -117,6 +129,7 @@ def perform(
url: str = "",
open_url: str = "",
signed_out: str = "",
guards: list | None = None,
frame: str = "",
text: str | None = None,
clear: bool = False,
@@ -142,6 +155,12 @@ def perform(
rng = rng or random.Random()
say = report or (lambda _m: None)
# `guards` is the runner's list of page guards; `signed_out` is the CLI's
# single --signed-out pattern. Same check either way, so fold them together.
guards = list(guards or [])
if signed_out:
guards.append({"id": "signed-out", "label": "signed out", "urlContains": signed_out})
# `frame` names the <iframe> the element lives in; the extension resolves the
# selector inside it and offsets the coordinates back. Absent for the common
# top-document case, and then locate behaves exactly as before.
@@ -150,7 +169,7 @@ def perform(
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
# Before any check that assumes we are on the real page.
check_signed_in(found, signed_out)
check_guards(found, guards)
# Give a transient overlay a chance to finish leaving before giving up on it.
if found.get("covered") and not force:
@@ -160,7 +179,7 @@ def perform(
while found.get("covered") and time.time() < deadline:
time.sleep(COVERED_POLL_SECONDS)
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_signed_in(found, signed_out)
check_guards(found, guards)
if not found.get("covered"):
say("it cleared")
@@ -231,7 +250,7 @@ def perform(
# Typing into a field that never took focus, or that ignores the input, fails
# silently and is otherwise indistinguishable from success.
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_signed_in(after, signed_out)
check_guards(after, guards)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")