Verify the platforms this project claims, and support more of them - #482
Merged
Conversation
The suite ran on windows-2022 alone for its whole life, so every platform assumption inside it went unmeasured on the two operating systems the project also supports. Linux and macOS join the matrix carrying the two ends of the Python range, since what differs between 3.10 and 3.14 is Python and what differs here is the OS. Linux runs under a real Xvfb rather than Qt's offscreen platform: the X11 backend opens a display at import time, and offscreen would hide the breakage this exists to find.
write("\b") had no key route on macOS: the table carries "backspace" but
neither "back" nor the raw character, so write() fell through to its space
fallback and typed a space where a backspace was asked for. X11 and Wayland
both carry the raw character; macOS was the one that did not.
system_profiler reports a symbolic vendor id for Apple's own devices —
apple_vendor_id, not a number — and that went straight into a field
documented as four hex digits. Its leading "a" is a valid hex digit, so a
lenient parse turns it into 000a; only a real id can satisfy the contract,
so anything else is now None. The device is still listed and its
manufacturer still names the vendor.
Wayland ended up with five jobs that check what reaches a real peer. X11 — the older and more widely deployed of the two Linux paths — had none: every X11 assertion in the suite is made against a mock of python-Xlib, so nothing had confirmed that an injected event reaches a client at all, that it arrives as real input rather than a sent event, or that a captured pixel is the pixel on screen. Ground truth comes from other codebases than the subject: xev is a real X client that prints every event delivered to its window, ImageMagick's import is an independent grabber against a root painted two asymmetric colours, and xdotool and xdpyinfo are the server answering for itself. It runs over one monitor and then two. The assertion worth naming is `synthetic NO`: XSendEvent traffic arrives with YES and is discarded by most toolkits, so a backend that quietly stopped driving real input would still pass any check that only counted events. There is no negative-origin pass because X11 cannot have one — the root window is the union of every monitor and always begins at (0, 0). It also pins a cross-platform split it found: on Linux the scroll direction comes from scroll_direction and the sign of the count is discarded, while Windows and macOS read the direction off that sign. Portable code written against the Windows convention scrolls the wrong way here. Recorded in Progress.md as a decision rather than changed under the maintainer.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 951 |
| Duplication | 37 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
macOS is the one supported platform with no container to put it in, and every macOS row in the capability matrix said "implementation": the code was there and nothing had ever run it on a Mac. Two of these capabilities are gated by TCC, which asks a user to grant Screen Recording and Accessibility, and a CI runner has no user to ask. Which of them a runner grants is not something to guess at — guessing is how the Wayland work twice recorded a desktop's refusal as a container's limitation. So the probe runs in --measure mode: it reports what the runner permits and asserts nothing. Its EXPECTED table starts empty and refuses to pass in assert mode while it is, because a gate that asserts nothing reads as coverage that does not exist. Also clears four Sonar findings this branch introduced. Two are the pip lines the matrix change moved: a `run: |` block scalar swallows NOSONAR entirely, so each command becomes its own single-line step that can carry its own justification. The third is the X11 image running as root, which it has no reason to do — unlike the Wayland image, whose XDG_RUNTIME_DIR ownership is the reason that one is privileged.
A macos-14 runner grants both Screen Recording and Accessibility, so every capability the probe covers works on one: capture returns real pixels rather than the black rectangle a refusal produces, CGEventPost moves the cursor and the move reads back exactly, and the AX walk returns real elements. The usual assumption that CI cannot exercise a TCC-gated macOS API is wrong for this runner, which is the whole reason it was measured instead of reasoned about. EXPECTED now holds that measurement and the --measure flag comes off, so a capability appearing or disappearing turns the job red and names it.
Window management was Windows-only for the project's whole life: the facade
branched on sys.platform and raised NotImplementedError everywhere else,
which left 23 AC_* commands and their MCP tools dead on macOS and Linux. It
now goes through the same backend seam the accessibility, OCR and vision
subsystems already use — Win32, EWMH over python-Xlib on X11, Quartz plus the
accessibility API on macOS, and a null fallback that lists nothing and
refuses actions with a reason.
The seam sits under wrapper/ rather than utils/ because it has to import
windows/, linux_with_x11/ and osx/, and utils/ is above those in the layering.
Refusals now raise a class that is both an AutoControlException and a
NotImplementedError. The GUI tabs and the REST handler already catch the
latter to say "not on this platform", and that keeps working; the executor
and the background loops catch the former, and a bare NotImplementedError
slipped past all of them — aborting a whole script where one action should
have been reported as failed.
The x11-verification job grew a second script that drives the public facade
against a real openbox session, with ground truth from xwininfo and xprop.
Two things only a real window manager could show up were wrong first time:
* The rectangle is the frame, not the client. Win32's GetWindowRect returns
the frame and every caller here is written against that, so reporting the
client area was off by the decorations on X11 alone.
* A move has to go through _NET_MOVERESIZE_WINDOW. Under a reparenting
window manager a client's own x/y are relative to its frame, so a direct
ConfigureWindow asks in the wrong coordinate space: asking openbox for
(300, 220) that way landed the window at (302, 260).
post_key_to_window and post_click_to_window are asserted to arrive flagged
synthetic, because that is what XSendEvent traffic is and GTK and Qt discard
it by design — the same best-effort caveat Win32's PostMessage carries. macOS
has no equivalent at all, so the backend refuses rather than reporting a
success that went to whatever had focus.
The macOS probe goes back to --measure for one round: its window probe is new
and asserting a value nobody has measured is the guess this all avoids.
Linux had none: the selector fell through to the null backend while the capability matrix claimed "backend tests" for Linux X11. AT-SPI2 is a D-Bus protocol rather than a library, which is what makes it reachable without a new dependency — pyatspi and gi.repository.Atspi are distribution packages built against the system introspection data and cannot be installed into a virtual environment, so depending on them would be depending on something most users cannot get. The D-Bus client written for the portal handshake moves from linux_wayland/ to utils/dbus_client/ to make that possible: utils/ sits above the per-OS packages, so an accessibility backend reaching down into a platform backend to borrow its D-Bus code would invert the layering. The old path re-exports it, so the portal code is untouched; the client's own tests follow it, since a shim forwarding private names too would be a second copy of the surface to keep in step. Verifying it against a real bus and a real GTK application immediately found a gap in that client: it could not demarshal signed integers. The portal never needed one, and AT-SPI reports a component's extents as four signed values, because a window on a monitor left of or above the primary one is at a negative coordinate — so the backend could read a tree but not where anything in it was. The whole fixed-width numeric set marshals now, except UNIX_FD, which stays an error on purpose: it is an index into a descriptor array this client does not receive, so returning it would hand a caller a number that addresses nothing. The x11-verification job grew a third script for it, against zenity on a D-Bus-activated accessibility bus. Neither half can be mocked usefully — an application only appears on the bus if its toolkit bridge loaded, and the tree's shape is the toolkit's business. Because AT-SPI is a bus rather than a display protocol, this is the one capability where Wayland is not the restricted case: the same bus serves both Linux sessions.
A macos-14 runner was measured to have no ordinary application windows at all, so a probe that asserted a count would be asserting a property of the runner image rather than of this project — and would go red the day that image happens to open one. It now asserts that the backend is selected, the Quartz query runs and every window it does return can be described, and reports the counts either side of the layer filter so an empty session and a filter that dropped everything stay told apart.
platform_wrapper refused to start on anything that was not win32/cygwin/msys, darwin or linux/linux2, and each of the seven X11 backend modules carried its own copy of the same Linux-only guard. A FreeBSD, OpenBSD or NetBSD desktop runs the same X server and the same python-Xlib, so all of that was refusing a system the code already worked on — and python-Xlib was pinned to platform_system=='Linux', so relaxing the guards alone would have left the backend without its one dependency. sys.platform was being compared against literal lists in over a hundred places, and every list named the same three families, so the fix is one place that decides: utils/platform_id. The guards now ask is_x11_unix() — "is this an X11 unix", which is the question they were always trying to ask — rather than whether the kernel is Linux. None of that is worth anything unless something runs it on a BSD, and no hosted runner is one, so the freebsd job boots a real FreeBSD 14 VM inside the runner: it asserts the classification, then imports the X11 modules under a real X server and moves the pointer and reads it back. It covers the platform layer rather than the whole package, because opencv has no FreeBSD wheel and a source build would take an hour or fail — that limit is stated in the job rather than left to be discovered. arm64 joins the smoke matrix as ubuntu-22.04-arm and windows-11-arm (macos-14 was already arm64). The dependency set is where the architecture shows: opencv, pillow and cryptography all ship native wheels, and a missing one is a source build rather than a clean failure.
WHATS_NEW and both translations describe the six phases; CHANGELOG records the compatibility-visible parts — the window backends, the AT-SPI backend, platform_id, the new exception class and the D-Bus client's move. Progress.md gains the macOS recorder: OSXRecorder is a complete implementation that the platform wrapper never selects, and that is not an oversight. osx_listener builds an NSApplication at import time and stopping a recording needs a blocking run loop, so wiring it up as it stands would move both onto the import path of the whole package. The capability matrix already says 'unavailable', which matches.
check_key_is_press after a posted shift returned True on one runner round and False on the next: CGEventSourceKeyState reflects the window server's state, and the posted event has to reach it first. Reading once made the probe a coin toss, and a gate that is a coin toss is worse than no gate. Also records what the window probe measured: Quartz reports five on-screen windows on a macos-14 runner and none of them is at the application layer, so the count stays reported rather than asserted.
JE-Chen
marked this pull request as ready for review
August 19, 2026 20:12
py311-pip and py311-xlib are not in FreeBSD 14.2's repository: the flavoured port names depend on which Python flavour the release defaults to. Only the interpreter and the X server come from pkg now; python-Xlib is pure Python, so pip installs it without a compiler and at the version the project pins.
Windows CI caught this as one response where two were expected, on one matrix cell out of nine. It is not flakiness in the test: tools/call runs on a worker thread under stdio, and serve_stdio's `finally` restores the previous writer as soon as the loop reaches EOF. A worker that had not reached its write yet then read `self._writer` and found it already swapped back — so the reply went down the previous connection, or was dropped with "MCP async tool reply with no writer". Two halves. The writer is now captured when the call is dispatched, so a reply always belongs to the transport that accepted the request; and the transport drains its in-flight workers before restoring anything. The drain is bounded, and names the worker that outstayed its welcome rather than hanging shutdown on one bad tool. Both new tests fail without the fix and log the exact production symptom. Widening the matrix is what surfaced this: the race needs a slow enough machine to lose, and windows-2022 on 3.10 was slow enough.
opencv-python publishes no win_arm64 wheel, so pip falls back to building it from source and CMake cannot configure for ARM64. The job spent twelve minutes failing at that. This is not a CI problem to work around — the package genuinely cannot be installed on Windows arm64 today, so the cell comes out of the matrix and the reason goes into the workflow comment, the capability matrix and Progress.md, with the runner ready to add back when the wheel exists. Linux arm64 is fine: ubuntu-22.04-arm passes on both Python versions, and macos-14 was already arm64. Windows is the only combination blocked.
The screen module imports Pillow at module scope, and Pillow is one of the heavy dependencies this job deliberately does not install — it has no FreeBSD wheel either. Its guard is the same one every other X11 module carries, so dropping it from the probe costs no coverage of the change. What runs instead is the display, keymap, mouse and keyboard modules: a real pointer move on a real X server read back from the server, and an XTest key injection the platform accepts. That is the claim being made.
Importing anything under je_auto_control runs the package facade, which imports Pillow and OpenCV at module scope — so 'just the platform layer' is not something the import system will hand over, and both probes died on PIL before reaching what they were checking. The dependencies come from FreeBSD's own repository rather than from pip: neither publishes a FreeBSD wheel, and building OpenCV from source in a CI VM does not finish. Each package installs on its own line so a name that is not in the repository names itself in the log instead of taking the step down with it — and nothing is skipped quietly, because whatever is genuinely missing still surfaces as an ImportError. The platform_id probe now loads that module by file path instead: the classification is what decides which backend a BSD gets, and it should be checkable without dragging OpenCV in behind it.
The job was trying to import the X11 backend, which means importing the package facade, which imports OpenCV and cryptography at module scope. Neither publishes a FreeBSD wheel; installing them from ports pulled a dependency tree that had not finished after fifty minutes, so the run was cancelled. A smoke job cannot cost an hour. What a BSD is uniquely needed to answer is the classification itself: sys.platform really reads freebsd14 there, and is_x11_unix() — the question every relaxed guard now asks — returns True on it. utils/platform_id imports nothing but sys, so it loads by file path with no dependencies at all, and that is what the job runs. Driving real input on a BSD stays uncovered, and Progress.md says so with the five lines that would close it on a machine that has the dependency set.
OSXRecorder was a complete implementation sitting behind recorder = None, and that was not an oversight: the listener called NSApplication.sharedApplication() at import time, and stopping a recording meant AppHelper.runEventLoop(), a loop that never returns to its caller. Wiring it up would have put both on the path of import je_auto_control. The premise is wrong. A CGEventTap needs a run loop, not an application: create the tap on a dedicated thread, add its source to that thread's run loop, and pump the loop in short CFRunLoopRunInMode slices so a stop flag is honoured between them. Nothing touches AppKit, nothing runs at import, and record() returns immediately. The macOS hotkey backend had been driving a tap exactly this way in the same tree. The tap is listen-only, which is load-bearing rather than a detail: a recorder that consumed events would swallow the input it is recording. Two defects were in that code and only a Mac could show either. Coordinates came from NSEvent.mouseLocation(), a bottom-left origin, while every replay posts into the top-left space osx_mouse uses -- so a click recorded near the top of the screen replayed near the bottom. And modifiers were not recorded at all: macOS sends no key-down for Shift, Control, Option or Command, only a flagsChanged carrying the new flag set, so a recording could not say a modifier was held across what followed. Everything after the capture is platform-neutral and now lives in utils/input_macro/recorder_base.py, which both backends subclass. A second hand-written copy of the queue and timeline shaping would diverge silently, and it would surface as a recording made on one OS replaying wrongly on the other. Verifying that end to end turned up a defect that was never macOS-specific: replay_timeline's dispatch table held the run_sequence DSL's vocabulary and the recorders emit their own, and the two sets were disjoint. Feeding stop_record_timeline() to replay_timeline() -- the pipeline the docstrings and the ac_record_stop_timeline tool both prescribe -- matched no handler, replayed an empty session, and returned every event as played. The macos-capabilities job records a real session on a real window server now, and the README platform table stops claiming gaps the code does not have: recording on macOS, and window management on macOS and Linux/X11, which landed earlier on this branch without the table being updated.
cmd_record stopped short-circuiting on darwin when macOS got a working recorder, but test_record_subcommand_delegates_to_helper still asserted the old refusal, so both macos-14 pytest-headless jobs failed on rc 0 == 1. The branch was only ever describing the gate; with the gate gone the delegation assertions apply on every platform.
Progress.md recorded the missing BSD input coverage as needing "a machine with the dependency set on it, not a different CI trick". That was the mistake its own Wayland section warns about three lines earlier: asking what the environment cannot do instead of asking who actually cannot. It was not FreeBSD that could not run the X11 backend. It was this package, which imported five image and crypto wheels before it would let you move a pointer. Ten modules on the facade's import path pulled in OpenCV, NumPy, Pillow, je_open_cv or cryptography at module scope, across about sixty call sites, while most of utils/ had been importing OpenCV lazily all along with the docstrings to say so. Those ten now do the same; the Pillow annotations moved under TYPE_CHECKING and the two ImageSource aliases keep Pillow in the union as a forward reference. What import je_auto_control needs is defusedxml, plus python-Xlib on X11 -- both pure Python. The five stay hard dependencies and still install by default; what changes is when a missing one is reported. So the FreeBSD VM installs python-Xlib, defusedxml and an X server in seconds where the ports build had not finished in fifty minutes, and freebsd_verify.py runs the backend rather than the guard. Ground truth is the X server answering for itself: query_pointer for the cursor and the button mask, query_keymap for whether an injected key really went down, and a mapped X window that asked for button events for the wheel. That last one was needed for a defect nothing else could see: mouse_scroll matched Windows, then macOS, then a literal ["linux", "linux2"] -- one of the hand-written platform lists platform_id exists to replace -- so on a BSD it fell off the end of the chain with no backend call, no exception and no log line. A wheel event never appears in the pointer mask, because X11 delivers a scroll as a press and release of button 4/5/6/7 too fast to sample. The scroll direction question that had been sitting at DECIDE is settled the way the maintainer chose: the sign of scroll_value reverses the direction on every platform, and scroll_direction names the direction a positive count takes. X11 turned the sign back into the opposite button; Wayland had the same abs() in _wheel_deltas and lost it the same way. Migration for anyone relying on the magnitude alone is in CHANGELOG.md. test_facade_import_is_light.py blocks all five wheels in a subprocess and imports the facade anyway, because one convenience import undoes this silently and every runner with wheels keeps passing.
FreeBSD 14.2's package repository has python311 but not py311-pip, so `pkg install py311-pip` fails outright and the job never reaches the verification. The flavoured port names are the unreliable part here, which is also why the two dependencies come from PyPI by their own names rather than as py311-xlib and py311-defusedxml. Both are pure Python, so pip is enough. The externally-managed retry goes with it: FreeBSD does not mark the system Python that way, and an ensurepip-installed pip owns what it installed. Also re-measures _factories.py in the Progress.md exemption list, which the scroll tool's description moved from 8,968 to 8,972 lines.
python-Xlib 0.33 imports six from Xlib.display, and --no-deps meant nothing installed it, so the verification died on ModuleNotFoundError before it reached a single check. Dropping --no-deps would have fixed it and thrown away what the flag is there for: it is what proves nothing heavy is being pulled in behind the job that exists to show the backend needs nothing heavy. six is pure Python and now named alongside the other two. Everything before that point worked on the first run: ensurepip bootstrapped pip, Xvfb came up on :99, and sys.platform read freebsd14.
The FreeBSD VM was added to test the X11 backend and never got there: FreeBSD's python311 has no sqlite3 — it is the separate databases/py-sqlite3 package — and ten subsystems imported it at module scope, every one of them reachable from the facade. So `import je_auto_control` failed outright on a stock FreeBSD, on a machine where moving a mouse needs no database at all. Same shape as the OpenCV finding one commit earlier, from a direction no reasoning about wheels reaches: the standard library is not the same size on every platform. They go through utils/sqlite_support now, which fails at the first call that opens a database instead of at import, and raises the unsupported-operation type the GUI tabs, the REST handler and the executor already report as "not available here" rather than an ImportError none of them catch. HistoryStore connects on first use for the same reason its singleton is built while the facade is importing — which is also why importing the package no longer creates ~/.je_auto_control as a side effect. The FreeBSD job asserts the module is absent, so an image that later ships py311-sqlite3 goes red instead of quietly retiring the property.
Codacy reports "subprocess function 'run' without a static string" on the call line and suppresses only a marker on that exact line — the comment above does not count, unlike Semgrep itself. Both probes build argv from sys.executable plus a module-level literal, with no shell.
Round 3 reparented the exception hierarchy so the executor, the poll loops, the request handlers and the GUI slots could each contain the whole family in one except. Five classes were missed, and one was reachable from an action list: AC_config_import on a malformed bundle raised ConfigBundleError, which inherits Exception directly, so the per-action clause did not catch it — the error went past the boundary and took every remaining action with it, under raise_on_error=False, where the contract is that a failed action is recorded. Measured on a two-action list that lost its second action. AC_usb_remote_devices and AC_usb_remote_open had the same path through UsbClientError. The guard is structural rather than a list of the five: the new test walks the package with ast and fails on any class inheriting Exception directly, against an allowlist that must say why — LoopBreak and LoopContinue because a family handler swallowing a break is the mirror-image bug, the MCP error carrier because it never leaves the dispatcher that raised it.
The history store's constructor opened its database, and that constructor runs while the facade is importing — so importing the package created ~/.je_auto_control/run_history.sqlite whether or not the caller ever recorded a run. The check reads the whole home directory rather than that one path, because what puts it back is any module-level singleton that opens a file, not that path in particular.
Reading the whole home directory would have gone red for a third-party import writing its own cache there, which is someone else's business and would read as our regression. Every store, key file and cache this package owns is under ~/.je_auto_control, so its absence is the property.
The entry blamed opencv-python alone and proposed moving OpenCV and Pillow to an optional extra to unblock the install. Re-measuring shows that fix would not have worked, and that half of it was aimed at the wrong package: - cryptography is a second, independent blocker. Wheels stop at 46.0.3; 46.0.4 onwards publish no win_arm64. Our floor is >=48.0.1, set to clear GHSA-537c-gmf6-5ccf, so it cannot be lowered to reach one. - Pillow ships win_arm64 wheels and never blocked anything. - PySide6 and qt-material resolve, so the GUI extra is not implicated; aiortc fails on its own transitive google-crc32c. None of this needs an arm64 runner: pip resolves for a foreign platform with --dry-run --only-binary=:all: --platform win_arm64, which answers in seconds what previously cost twelve minutes of a runner to fail at. The command is now recorded next to the finding, so the next re-check is cheap.
The docs promise JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE gates every destructive tool, caveated only on the client advertising elicitation. Measured against a real HttpMCPServer, it fires on stdio and never fires over HTTP: a plain POST has no server-to-client channel to carry the prompt, and an SSE POST closes its connection, so the capability the client advertised at initialize is forgotten before the next call. The operator gets no confirmation and one INFO line. Document the limit in both translations, point at the controls that do work over HTTP, and pin the current behaviour so closing the gap reddens a test instead of leaving a stale warning. Which fix to take is a behaviour change either way, so it goes to Progress.md as a decision.
The destructive-action confirmation fired on stdio and never over HTTP. The prompt is a question asked between receiving a call and answering it, so it needs a channel bound to the scope that received initialize — and that scope was keyed on the TCP connection, which a plain POST ends and an SSE POST closes after its last event. The capability advertised at initialize was always gone by the tools/call. Key the scope on Mcp-Session-Id instead. initialize mints one and returns it as a header; GET with an SSE Accept opens the standing server-to-client stream; DELETE terminates. The dispatcher needed no change, since it already scoped capabilities and call slots on an opaque connection_id, so per-connection isolation is preserved as-is — a session is just an identity that outlives a socket. Sessions are swept when idle and capped. A client that echoes the id and gives the server somewhere to send the question is now prompted for real, over either the standing stream or an SSE POST's own response stream. One that does neither still proceeds, as a stdio client without elicitation does; that fallback is now documented and pinned rather than being the only behaviour. Also stop draining a request body that was already read. The new 404 and 409 are decided after parsing, as the "body must be UTF-8" 400 always was, and the drain then blocked on bytes that were gone until the read timeout.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



The suite ran on
windows-2022alone for its whole life, plus one Linuxcontainer run. macOS got two commands and nothing else. Wayland had five jobs
reading input back off a real peer; X11 — the older and more widely deployed
of the two Linux paths — had none, and every X11 assertion in the suite was
made against a mock of
python-Xlib.Six phases, each a commit group, each CI-gated.
1 — The suite runs where the project says it runs
pytest-headlessbecomes an OS matrix: Windows keeps all five Pythons, Linuxand macOS carry the two ends of the range. Linux runs under a real Xvfb
rather than Qt's offscreen platform, because the X11 backend opens a display
at import time and offscreen would hide exactly the breakage this exists to
find.
It found two real macOS defects on the first run:
write("\b")had no key route on macOS, so it fell through to the spacefallback and typed a space where a backspace was asked for.
system_profilerreports a symbolic vendor id for Apple's own devices(
apple_vendor_id), and that went into a field documented as four hexdigits. Its leading
ais a valid hex digit, so a lenient parse yields000a.2 — X11 input is read back out of a real client
A new
x11-verificationjob against a real Xvfb server with a real windowmanager. Ground truth comes from other codebases than the subject:
xev, areal X client that prints every event delivered to its window; ImageMagick's
importagainst a root painted two asymmetric colours;xdotoolandxdpyinfo.The assertion worth naming is
synthetic NO.XSendEventtraffic arriveswith
YESand is discarded by most toolkits, so a backend that quietlystopped driving real input would still pass any check that only counted
events.
There is deliberately no negative-origin pass: on X11 the root window is the
union of every monitor and always begins at
(0, 0). That is a protocoldifference from Wayland, not an untested case.
It also pinned a cross-platform split it found — on Linux the scroll
direction comes from
scroll_directionand the sign of the count isdiscarded, while Windows and macOS read the direction off that sign. Recorded
in
Progress.mdas a decision rather than changed unilaterally.3 — macOS is fully testable in CI, contrary to the usual assumption
Measured first, asserted second. A
macos-14runner grants both ScreenRecording and Accessibility: capture returns real pixels rather than the
black rectangle a refusal produces,
CGEventPostmoves the cursor and themove reads back exactly, and the AX walk returns real elements.
4 — Window management is no longer Windows-only
The facade branched on
sys.platformand raised everywhere else, leaving 23AC_*commands and their MCP tools dead on macOS and Linux. It now goesthrough a backend seam — Win32, EWMH over
python-Xlib, Quartz plus theaccessibility API — with a null fallback that lists nothing and refuses
actions with a reason.
Two things only a real window manager could show up were wrong first time:
GetWindowRectreturns the frame and every caller is written against that.
_NET_MOVERESIZE_WINDOW. Asking openbox for(300, 220) with a direct
ConfigureWindowlanded the window at (302, 260).Refusals now raise a class that is both an
AutoControlExceptionand aNotImplementedError: the GUI and REST handlers already catch the latter,and the executor catches the former — a bare
NotImplementedErrorslippedpast every containment boundary.
5 — Linux has an accessibility backend
Over AT-SPI2, which is a D-Bus protocol rather than a library — that is
what makes it reachable without a new dependency, since
pyatspiandgi.repository.Atspicannot be installed into a virtual environment. TheD-Bus client moved from
linux_wayland/toutils/dbus_client/to make thatpossible without inverting the layering.
Verifying it against a real bus and a real GTK application found a gap in
that client: it could not demarshal signed integers. AT-SPI reports
extents as four signed values, because a window on a monitor left of the
primary one is at a negative coordinate — so the backend could read a tree
but not where anything in it was.
6 — The BSDs, and arm64
platform_wrapperrefused anything that was not win32/cygwin/msys, darwin orlinux/linux2, and each of the seven X11 backend modules carried its own copy
of the same guard.
sys.platformwas compared against literal lists in overa hundred places, so the fix is one place that decides:
utils/platform_id.A
freebsdjob boots a real FreeBSD 14 VM inside the runner, imports the X11modules under a real X server, and moves the pointer and reads it back.
ubuntu-22.04-armandwindows-11-armjoin the smoke matrix.