Files
nucleic/guest/nucleic-linux-agent/spike/ax_dump_spike.py
T
abkslmandClaude Opus 4.8 a8c8432ccd Add phase-0 AT-SPI spike for Linux AX agent
Proves AT-SPI2 can back the Linux ax_* ops on Debian/Ubuntu arm64:
ax_dump emits the same node JSON as the macOS agent, and ax_press/
ax_set_value/ax_focus map to AtspiAction/EditableText/Component. Ran
green on Debian 12 arm64, confirming the AT-SPI approach and the X11
screen-coordinate assumption. De-risks docs/LINUX_VM_AX_AGENT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-10 05:09:15 +00:00

226 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Phase-0 AT-SPI spike for the Nucleic Linux semantic-control agent.
Proves the AT-SPI2 approach that will back the Linux `ax_*` ops (docs/LINUX_VM_AX_AGENT.md):
1. Read the accessibility tree and emit it as the SAME `node` JSON the macOS agent produces
(ref/role/subrole/title/value/enabled/focused/frame/actions/children) — so the host side needs
no protocol change.
2. Act on controls BY IDENTITY: do_action (press), set_text_contents (set value), grab_focus.
3. Report both SCREEN and WINDOW extents, so we can confirm the load-bearing assumption behind the
Wayland->X11 switch: that SCREEN coordinates are real (needed by the pixel fallback + hit-test).
Usage:
ax_dump_spike.py --app "Nucleic AX Probe" [--act]
Exits non-zero on failure so run_spike.sh can gate CI on it.
"""
import argparse
import json
import sys
import pyatspi
# AT-SPI role names are lowercase-spaced ("push button"); the host is role-string-agnostic (macOS AX
# uses "AXButton"), so we pass the AT-SPI role name through verbatim rather than inventing a mapping.
def _extents(acc, coord):
try:
comp = acc.queryComponent()
except NotImplementedError:
return None
try:
e = comp.getExtents(coord)
return {"x": e.x, "y": e.y, "w": e.width, "h": e.height}
except Exception:
return None
def _actions(acc):
try:
act = acc.queryAction()
except NotImplementedError:
return []
return [act.getName(i) for i in range(act.nActions)]
def _value(acc):
# Prefer editable/text content, then a numeric Value, else the accessible's description-free value.
for query, attr in (("queryText", "text"),):
try:
t = getattr(acc, query)()
return t.getText(0, -1)
except NotImplementedError:
pass
except Exception:
pass
try:
return acc.queryValue().currentValue
except NotImplementedError:
return None
except Exception:
return None
def node(acc, reg, depth, max_depth, budget):
"""Serialize one accessible to the macOS-identical `node` shape, assigning a stable `ref`."""
if budget[0] <= 0:
return None
budget[0] -= 1
ref = "e%d" % len(reg)
reg[ref] = acc
try:
states = acc.getState()
enabled = states.contains(pyatspi.STATE_ENABLED) or states.contains(pyatspi.STATE_SENSITIVE)
focused = states.contains(pyatspi.STATE_FOCUSED)
except Exception:
enabled, focused = True, False
n = {
"ref": ref,
"role": acc.getRoleName(),
"title": acc.name or None,
"value": _value(acc),
"enabled": bool(enabled),
"focused": bool(focused),
"frame": _extents(acc, pyatspi.DESKTOP_COORDS),
"frameWindow": _extents(acc, pyatspi.WINDOW_COORDS), # spike-only: compare to catch bad coords
"actions": _actions(acc),
"children": [],
}
if depth < max_depth:
try:
count = acc.childCount
except Exception:
count = 0
for i in range(count):
try:
child = acc.getChildAtIndex(i)
except Exception:
child = None
if child is None:
continue
c = node(child, reg, depth + 1, max_depth, budget)
if c is not None:
n["children"].append(c)
return n
def find_app(name):
desktop = pyatspi.Registry.getDesktop(0)
for i in range(desktop.childCount):
app = desktop.getChildAtIndex(i)
if app is not None and (app.name or "") == name:
return app
# Fall back to substring match — app names sometimes carry a suffix.
for i in range(desktop.childCount):
app = desktop.getChildAtIndex(i)
if app is not None and name in (app.name or ""):
return app
return None
def find_by_title(root, title):
if (root.name or "") == title:
return root
for i in range(root.childCount):
c = root.getChildAtIndex(i)
if c is None:
continue
hit = find_by_title(c, title)
if hit is not None:
return hit
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--app", required=True, help="target application accessible name")
ap.add_argument("--max-depth", type=int, default=25)
ap.add_argument("--budget", type=int, default=800) # mirror the macOS 800-node cap
ap.add_argument("--act", action="store_true", help="also exercise do_action/set_text/grab_focus")
args = ap.parse_args()
app = find_app(args.app)
if app is None:
desktop = pyatspi.Registry.getDesktop(0)
names = [desktop.getChildAtIndex(i).name for i in range(desktop.childCount)]
print("FAIL: app %r not on the AT-SPI bus. Visible apps: %r" % (args.app, names), file=sys.stderr)
return 2
reg = {}
tree = node(app, reg, 0, args.max_depth, [args.budget])
print(json.dumps(tree, indent=2, ensure_ascii=False))
print("\n[spike] dumped %d nodes" % len(reg), file=sys.stderr)
# Validate the X11 decision: every on-screen control should have real (non-zero) SCREEN extents.
bad = []
# A top-level window can legitimately sit at the origin (esp. headless X with no WM), so only flag
# *leaf-ish* controls that claim (0,0) — those indicate the SCREEN coordinate space isn't resolving.
_containers = ("application", "filler", "frame", "window", "panel", "scroll pane")
def check(n):
f = n.get("frame")
if f and f["w"] > 0 and f["h"] > 0 and f["x"] == 0 and f["y"] == 0 and n["role"] not in _containers:
bad.append(n["ref"])
for c in n["children"]:
check(c)
check(tree)
if bad:
print("[spike] WARN: %d nodes report (0,0) SCREEN origin (coords suspect): %r" % (len(bad), bad[:8]), file=sys.stderr)
else:
print("[spike] SCREEN coordinates look real (non-zero origins present) — X11 assumption holds", file=sys.stderr)
if not args.act:
return 0
# --- act by identity: the three primitives ax_press / ax_set_value / ax_focus map to ---
ok = True
entry = find_by_title(app, "probe-entry")
if entry is not None:
try:
entry.queryComponent().grabFocus() # ax_focus
entry.queryEditableText().setTextContents("hello from at-spi") # ax_set_value
got = entry.queryText().getText(0, -1)
print("[spike] set_text -> %r" % got, file=sys.stderr)
ok = ok and (got == "hello from at-spi")
except Exception as e:
print("[spike] FAIL entry actions: %r" % e, file=sys.stderr)
ok = False
else:
print("[spike] FAIL: probe-entry not found", file=sys.stderr)
ok = False
button = find_by_title(app, "probe-button")
status = find_by_title(app, "probe-status")
if button is not None and status is not None:
try:
before = _value(status)
act = button.queryAction() # ax_press
names = [act.getName(i) for i in range(act.nActions)]
idx = names.index("click") if "click" in names else 0
act.doAction(idx)
new_status = _value(status) # the label's TEXT value is what the click mutates
print("[spike] pressed button (action %r) -> status text %r (was %r)" % (names[idx], new_status, before), file=sys.stderr)
ok = ok and (new_status == "clicked")
except Exception as e:
print("[spike] FAIL button action: %r" % e, file=sys.stderr)
ok = False
else:
print("[spike] FAIL: probe-button/probe-status not found", file=sys.stderr)
ok = False
print("[spike] ACT RESULT: %s" % ("PASS" if ok else "FAIL"), file=sys.stderr)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())