Skip to content

Verify the platforms this project claims, and support more of them - #482

Merged
JE-Chen merged 30 commits into
mainfrom
feat/cross-platform-verification
Aug 20, 2026
Merged

Verify the platforms this project claims, and support more of them#482
JE-Chen merged 30 commits into
mainfrom
feat/cross-platform-verification

Conversation

@JE-Chen

@JE-Chen JE-Chen commented Aug 19, 2026

Copy link
Copy Markdown
Member

The suite ran on windows-2022 alone for its whole life, plus one Linux
container 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-headless becomes an OS matrix: Windows keeps all five Pythons, Linux
and 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 space
    fallback and typed a space where a backspace was asked for.
  • system_profiler reports a symbolic vendor id for Apple's own devices
    (apple_vendor_id), and that went into a field documented as four hex
    digits. Its leading a is a valid hex digit, so a lenient parse yields
    000a.

2 — X11 input is read back out of a real client

A new x11-verification job against a real Xvfb server with a real window
manager. Ground truth comes from other codebases than the subject: xev, a
real X client that prints every event delivered to its window; ImageMagick's
import against a root painted two asymmetric colours; xdotool and
xdpyinfo.

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 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 protocol
difference from Wayland, not an untested case.

It also pinned 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. Recorded
in Progress.md as a decision rather than changed unilaterally.

3 — macOS is fully testable in CI, contrary to the usual assumption

Measured first, asserted second. A macos-14 runner grants both Screen
Recording and Accessibility: 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.

4 — Window management is no longer Windows-only

The facade branched on sys.platform and raised everywhere else, leaving 23
AC_* commands and their MCP tools dead on macOS and Linux. It now goes
through a backend seam — Win32, EWMH over python-Xlib, Quartz plus the
accessibility 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:

  • The rectangle is the frame, not the client. Win32's GetWindowRect
    returns the frame and every caller is written against that.
  • A move has to go through _NET_MOVERESIZE_WINDOW. Asking openbox for
    (300, 220) with a direct ConfigureWindow landed the window at (302, 260).

Refusals now raise a class that is both an AutoControlException and a
NotImplementedError: the GUI and REST handlers already catch the latter,
and the executor catches the former — a bare NotImplementedError slipped
past 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 pyatspi and
gi.repository.Atspi cannot be installed into a virtual environment. The
D-Bus client moved from linux_wayland/ to utils/dbus_client/ to make that
possible 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_wrapper refused 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 guard. sys.platform was compared against literal lists in over
a hundred places, so the fix is one place that decides: utils/platform_id.

A freebsd job boots a real FreeBSD 14 VM inside the runner, imports the X11
modules under a real X server, and moves the pointer and reads it back.
ubuntu-22.04-arm and windows-11-arm join the smoke matrix.

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.
@codacy-production

codacy-production Bot commented Aug 19, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 951 complexity · 37 duplication

Metric Results
Complexity 951
Duplication 37

View in Codacy

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
JE-Chen marked this pull request as ready for review August 19, 2026 20:12
JE-Chen added 17 commits August 20, 2026 04:25
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.
@sonarqubecloud

Copy link
Copy Markdown

@JE-Chen
JE-Chen merged commit 34448e5 into main Aug 20, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant