AnyBot/box/a11y

155 lines
5.8 KiB
Plaintext
Raw Normal View History

Bots click by number: every control on the screen is boxed and numbered, on every computer A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates from a picture, and the web tools' description told it they didn't work on its own computer. - marks.rs: the picture a bot gets has every button, link, field and menu item boxed and numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click / typing / scroll / mouse_move take the number (ref) and land on the control's centre. - Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets no number (topmost window at its centre must be the app's; the Dock's invisible full-screen window doesn't count). open_app waits until the app is really in front. - No going round in circles: a step that changed nothing says so; the same step (clicks within 16 px) a third time in a turn comes with a warning, from the fourth time it isn't run. - The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP, renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look (list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a minute (files and logins stay); a new box is never made from an older image. - Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt says web work goes through browser_* by number, and never to read browser cookie or history files. Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28, 153 unit tests; Linux build + 157 tests + x11_live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
#!/usr/bin/env python3
"""The controls on the box's desktop, from its accessibility tree (AT-SPI), as JSON:
{"elements": [{"role", "name", "value", "x", "y", "w", "h", "active", "flags", "window"}]}
in screen pixels. Web pages are in it too (Chromium runs with renderer accessibility on).
AnyBot numbers them on the bot's picture; a click by number lands on the centre.
Adapted from XBot's box/box-a11y. Usage: a11y [--limit N] [--budget SECONDS]
Exit 2 when AT-SPI itself is unreachable.
"""
import json
import os
import sys
import time
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Clicked or typed into (they get a number).
ACTIVE = {
"push button", "toggle button", "radio button", "check box", "link", "menu item", "check menu item",
"radio menu item", "tab", "page tab", "list item", "tree item", "combo box", "spin button", "slider",
"entry", "password text", "menu",
}
# Listed when they carry words (headings, labels, cells...).
NAMED = {"heading", "label", "static", "text", "image", "table cell", "column header", "row header", "icon",
"tool tip", "alert", "dialog", "paragraph"}
# The desktop's own parts are visible in the picture; the list is for the windows worked in.
SKIP_APPS = {"fluxbox", "a11y", "python3", "xterm-wrapper"}
def main():
limit, budget = 400, 3.0
args = sys.argv[1:]
for i, a in enumerate(args):
if a == "--limit":
limit = int(args[i + 1])
if a == "--budget":
budget = float(args[i + 1])
if not os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
try:
for line in open("/tmp/anybot-dbus.env", encoding="utf-8"):
k, _, v = line.strip().partition("=")
if k == "DBUS_SESSION_BUS_ADDRESS" and v:
os.environ[k] = v
except OSError:
pass
os.environ.setdefault("DISPLAY", ":1")
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
Atspi.init()
desktop = Atspi.get_desktop(0)
apps = desktop.get_child_count()
except Exception as exc: # noqa: BLE001
print(f"AT-SPI unreachable: {exc}", file=sys.stderr)
return 2
sw, sh = 0, 0
try:
geo = os.popen("xdotool getdisplaygeometry 2>/dev/null").read().split()
sw, sh = int(geo[0]), int(geo[1])
except Exception: # noqa: BLE001
pass
deadline = time.monotonic() + budget
out = []
def state(node):
try:
return node.get_state_set()
except Exception: # noqa: BLE001
return None
def extents(node):
try:
r = node.get_component_iface().get_extents(Atspi.CoordType.SCREEN)
return int(r.x), int(r.y), int(r.width), int(r.height)
except Exception: # noqa: BLE001
return None
def onscreen(b):
x, y, w, h = b
if w <= 0 or h <= 0 or x + w <= 0 or y + h <= 0:
return False
return not (sw and x >= sw) and not (sh and y >= sh)
def walk(node, depth, window):
if time.monotonic() > deadline or len(out) >= limit or depth > 45:
return
try:
role = node.get_role_name()
except Exception: # noqa: BLE001
return
st = state(node)
if depth > 1 and (st is None or not (st.contains(Atspi.StateType.SHOWING) and st.contains(Atspi.StateType.VISIBLE))):
return
box = extents(node)
if depth > 1 and (box is None or not onscreen(box)):
return
try:
name = (node.get_name() or "").strip()
except Exception: # noqa: BLE001
name = ""
if role in ("frame", "dialog", "window") and name:
window = name
editable = bool(st and st.contains(Atspi.StateType.EDITABLE))
value = ""
if (role in ("entry", "password text", "text", "spin button", "slider") or editable) and role != "password text":
try:
t = node.get_text_iface()
if t is not None:
value = (t.get_text(0, min(t.get_character_count(), 200)) or "").strip()
except Exception: # noqa: BLE001
pass
active = role in ACTIVE or (editable and role in ("text", "paragraph", "section", "document web"))
if depth > 1 and box is not None and (active or (role in NAMED and (name or value))):
flags = []
if st is not None:
for s, word in ((Atspi.StateType.FOCUSED, "focused"), (Atspi.StateType.CHECKED, "checked"),
(Atspi.StateType.SELECTED, "selected")):
if st.contains(s):
flags.append(word)
if not st.contains(Atspi.StateType.ENABLED):
flags.append("disabled")
x, y, w, h = box
out.append({"role": role, "name": name[:120], "value": value[:200], "x": x, "y": y, "w": w, "h": h,
"active": active, "flags": flags, "window": window})
try:
n = node.get_child_count()
except Exception: # noqa: BLE001
return
for i in range(min(n, 2000)):
try:
c = node.get_child_at_index(i)
except Exception: # noqa: BLE001
continue
if c is not None:
walk(c, depth + 1, window)
for i in range(apps):
try:
app = desktop.get_child_at_index(i)
name = app.get_name() or ""
except Exception: # noqa: BLE001
continue
if name in SKIP_APPS:
continue
walk(app, 0, name)
json.dump({"elements": out, "truncated": len(out) >= limit or time.monotonic() > deadline}, sys.stdout, ensure_ascii=False)
return 0
if __name__ == "__main__":
sys.exit(main())