From b1dccd7dec55631c8e8390fefc05ae782c58d6d2 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 14 Sep 2026 12:50:14 -0500 Subject: [PATCH] Revamp login guards into general per-firm page guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/autobuyer/runs/claim/route.ts | 5 +- clicker/actions.py | 57 +++-- clicker/runner.py | 305 +++++++++++++++++--------- lib/automations.ts | 166 ++++++++++---- lib/db.ts | 2 +- 5 files changed, 371 insertions(+), 164 deletions(-) diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts index d6adc70..babb1d1 100644 --- a/app/api/autobuyer/runs/claim/route.ts +++ b/app/api/autobuyer/runs/claim/route.ts @@ -1,5 +1,5 @@ import { claimRun } from '@/lib/db'; -import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations'; +import { findAutomation, resolveSteps, resolveGuards, resolveAuthSteps } from '@/lib/automations'; import { corsJson, corsPreflight } from '../../cors'; /** The Python runner polls this. Returns the run plus the steps to execute, so @@ -20,6 +20,9 @@ export async function POST() { firm: found.firm.label, label: found.automation.label, steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)), + guards: resolveGuards(found.firm), + // Pre-0.20 runners know nothing of guards; keep feeding them the + // old shape until they are restarted. authSteps: resolveAuthSteps(found.firm), }, }); diff --git a/clicker/actions.py b/clicker/actions.py index 3454796..2a139a9 100644 --- a/clicker/actions.py +++ b/clicker/actions.py @@ -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