Skip to content

Linux support: AT-SPI2 engine + shared session/MCP layers - #5

Open
NORTHTEKDevs wants to merge 17 commits into
mainfrom
feat/linux-engine
Open

Linux support: AT-SPI2 engine + shared session/MCP layers#5
NORTHTEKDevs wants to merge 17 commits into
mainfrom
feat/linux-engine

Conversation

@NORTHTEKDevs

@NORTHTEKDevs NORTHTEKDevs commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Ghost runs on Linux. ghost-mcp, ghost and ghost-http all build and run
there, exposing the same 20 MCP verbs as on Windows.

capabilities_for(Linux).functional is now true — the first time Ghost's
on-device rule has been satisfied for a platform other than Windows.

Verification

Both platforms are green in CI:

Workflow Job Result
Linux Build and unit test
Linux Live AT-SPI suite 10/10 on real Linux
Linux Release binaries (ELF + MCP stdio smoke test)
CI (Windows) Test ✅ 452 passing, unchanged
CI (Windows) Release build

The live suite is the point. GitHub runners have no desktop — but on Linux one
can be synthesised (Xvfb + dbus-run-session + at-spi-bus-launcher), so the
engine is tested against a real GTK application on every push. It proves both
halves of the wedge:

  • sets_and_reads_back_entry_text — text written through EditableText is read
    back from the application, with no keystrokes and no pointer movement.
  • invoking_a_button_through_atspi_has_a_real_effectAction.DoAction
    actually dismisses the dialog, observed by the window leaving the tree. A test
    that only asserted Ok would pass even if the app ignored it.

Plus window enumeration with real PIDs, role and name lookup, describe_screen
with real rectangles, PNG capture, and XTEST pointer control.

Shape

ghost-mcp / ghost-session          shared
      engine ──┬── Windows → ghost-core   Win32 UIA, SendInput, posted messages
               └── Linux   → ghost-linux  AT-SPI2, XTEST/portal/uinput, X11/portal

One cfg alias. Both engines expose the same module tree and signatures; window
handles are isize on both (0 = none) — an HWND on Windows, an interned
AT-SPI (bus name, object path) on Linux.

The wedge got better, not worse. docs/cross-platform.md had long warned
that background-without-focus-steal might degrade off Windows and should be
treated as "unknown → measure". Measured: AT-SPI actions make the application act
through its own toolkit, so nothing is raised and no pointer moves — a cleaner
guarantee than posted window messages, and identical under X11 and Wayland.
Synthetic input is only the fallback.

Pure Rust throughout (atspi/zbus, x11rb, ashpd, evdev): no -devel
packages to build, and it cross-compiles. The Wayland capture path uses the
one-shot Screenshot portal rather than ScreenCast + PipeWire, because Ghost
captures stills, not video — PipeWire would add C linkage and DMA-BUF handling
for nothing.

Bugs this caught before release

  • Verification threshold. With a 32×32 grid (1024 cells), one changed cell
    scores 0.00098 — under the old 0.002 threshold. A one-word text change would
    have verified as nothing happened, defeating the reason the grid is fine.
  • Editable fields were invisible. GTK exposes a single-line entry as AT-SPI
    role text, not entry, so role="edit" missed it. Matching now keys off the
    EditableText interface, which is the actual discriminator; a read-only
    label is also text and is still correctly excluded.
  • The MCP server refused to boot when the accessibility bus was unreachable.
    On Linux that is an ordinary condition, and dying at startup means the client
    shows "MCP server failed" with no diagnosis. The connection is now lazy: the
    server starts, answers tools/list, and the first real call returns the
    message naming the fix.
  • A CRLF install.sh would have failed on Fedora with bad interpreter: bash^M. .gitattributes pins *.sh to LF and the file is marked executable.

Not claimed

KeyInput, EditShortcuts and VisionGrounding are implemented but absent from
supported, because nothing has verified them end to end on Linux. The Wayland
paths (RemoteDesktop portal input, Screenshot portal capture) are likewise
unverified — CI runs X11. Tests assert both the presence and the absence of these
claims, so a future change that quietly widens them fails.

Reported as Unsupported rather than faked: per-window capture on native
Wayland, root capture under XWayland, local OCR, absolute pointer coordinates on
uinput, and window minimize/maximize/restore (close works).

docs/linux-fedora.md carries setup, the on-device checklist for the Wayland
paths, and troubleshooting. scripts/install.sh builds, installs, registers the
MCP server and runs ghost doctor.

Adds `ghost-linux`, the Linux counterpart to `ghost-core`. It mirrors that
crate's module tree and signatures so the shared session and MCP layers can
switch engines with a single cfg alias.

The architecture is inverted relative to Windows. There, background dispatch
(driving an app without stealing focus or moving the cursor) is built on posted
window messages. Linux has a cleaner analogue in AT-SPI2 actions: DoAction,
SetTextContents, SetCurrentValue and GrabFocus make the application act through
its own toolkit, with no synthetic input, no pointer movement and no consent
prompt - and identically under X11 and Wayland. So accessibility actions are the
primary path here and synthetic input is only the fallback for elements that
expose no usable action.

  tree/actions   atspi 0.30 over zbus
  async bridge   dedicated thread + current-thread runtime, double-bounded
  input X11      x11rb XTEST, keysym->keycode from the live server keymap
  input Wayland  RemoteDesktop portal, persisted+rotated restore token
  input headless uinput via evdev (GHOST_INPUT=uinput)
  capture X11    GetImage
  capture Wayland Screenshot portal (deliberately not ScreenCast/PipeWire)

Everything is pure Rust, so no -devel packages are needed to build and the crate
cross-compiles. Choosing the Screenshot portal over ScreenCast avoids linking
libpipewire for a frame stream Ghost never uses.

AT-SPI roles are mapped onto the same numeric id space the Windows engine uses,
so role_id_to_name, is_editable_role and the load-bearing edit->document alias
are identical across platforms and cannot drift.

Capabilities that genuinely do not exist are reported as Unsupported rather than
faked: per-window capture on native Wayland, root capture under XWayland, local
OCR, and absolute pointer coordinates on uinput.

Platform-independent modules (roles, handles, keysyms, verification) are not
cfg-gated, so their parity tests run on any host - including Windows CI, which is
where the role-table drift would otherwise go unnoticed.
Prepares ghost-core to sit behind a shared engine seam.

Window handles become `isize` across the public API (WindowInfo.hwnd,
find_by_name_in_hwnd, find_by_role_in_hwnd, find_all_in_hwnd,
BackgroundClicker::click, ensure_foreground). `0` keeps meaning "none", matching
the existing HWND(0) convention. HWND conversion moves inside the functions.
This is what lets the Linux engine offer the same signatures, where a handle is
an interned AT-SPI (bus name, object path) rather than a window handle.

Adds system::window with the three desktop reads the session layer needs and
cannot get from an element: foreground_window, cursor_pos, window_rect (plus
foreground_window_rect). The session layer used raw Win32 calls inline for these;
routing them through the engine is what removes its last Windows dependency.

Gates the crate to Windows with #![cfg(windows)] so it stays a plain workspace
member: off Windows it compiles to nothing and ghost-linux takes its place. Its
deps and the capture latency probe are gated the same way.

No behaviour change on Windows; the workspace suite stays green.
ghost-session and ghost-mcp become shared code. The locator tiers, grounding
cascade, reflection buffer, act-then-verify loop and the whole 20-verb MCP
surface are now written once and run on Windows and Linux; only the engine
underneath changes.

`ghost_session::engine` is the seam - a cfg alias to ghost-core on Windows and
ghost-linux on Linux. Both expose the same module tree (uia, input, capture,
system, process, ocr, error) and the same signatures, so call sites are
identical. ghost-mcp, ghost-cli and ghost-http get the same alias.

Removes the last Windows-specific code from the session layer: foreground
window, cursor position and window rect now go through engine::system, and the
HWND round trips are gone.

Adds cursor_unchanged, which treats an unreadable pointer position as unknown
rather than moved. Wayland exposes no pointer position to a client, and reporting
"moved" there would be wrong: background dispatch on Linux is an AT-SPI action,
which has no pointer involvement at all.

Drops dead ghost-core dependencies from ghost-cache and ghost-intent (neither
referenced it), and target-gates the rest.

All four binaries now build for x86_64-unknown-linux-gnu.
ghost_shell works on Linux. bash is the default (sh and zsh are accepted, pwsh
too if installed), and persistent sessions behave exactly as on Windows -
variables, cwd and env survive across send calls - using the same base64 framing
and per-session nonce sentinel, so command text never has to survive shell
quoting and a late sentinel from a timed-out command cannot be mistaken for a
later one.

ghost doctor gains real Linux checks, each mapping to a specific failure the
setup guide describes: session type, AT-SPI bus reachability, whether any
application actually exposes a tree, which input backend was selected, and a
capture probe.

The accessibility check is the important one. A bus that answers while zero
applications publish a tree is the signature of accessibility being switched
off, which is the most common Linux setup failure by a wide margin, so it gets a
specific remedy rather than a generic "no windows found".
Adds docs/linux-fedora.md: install, enable accessibility, build, register with
Claude Code, the on-device verification checklist, Wayland consent and unattended
uinput setup, known limitations and troubleshooting.

capabilities_for(Linux).functional stays false. The engine is implemented and
every binary cross-builds and links, but nothing here has run against a live
desktop, and the rule is that the flag flips only after on-device verification.
The status string says exactly that.

Gates the Windows live-app tests (Notepad, Calculator, CV probe) to Windows;
they drive Windows applications through the Windows engine and cannot compile
off it.
The repo's existing CI is Windows-only, and its release gate notes that GitHub
runners have no interactive desktop. That is true on Windows - but not on Linux,
where a full desktop can be synthesised in CI:

  Xvfb                -> an X11 display
  dbus-run-session    -> a session bus
  at-spi-bus-launcher -> the accessibility bus GTK publishes to

So the Linux engine gets what the Windows engine cannot have: automated live
verification against a real application on every push.

Adds crates/ghost-linux/tests/live_atspi.rs - ignored by default, run by CI with
--ignored. The tests drive a real GTK dialog (zenity) end to end: the a11y bus is
reachable, the window is listed with a real pid, its entry is findable by role,
its buttons by name, describe_screen returns elements with real rectangles, and
capture produces a valid PNG.

The one that matters is sets_and_reads_back_entry_text. It writes through AT-SPI
EditableText - no keystrokes, no pointer, no focus change - then re-resolves the
element and reads the value back from the application. That is Ghost's wedge, and
this proves it on Linux rather than asserting it.

The workflow also confirms the release binaries are ELF and smoke-tests the MCP
server over stdio (initialize + tools/list) so a binary that cannot even speak
its own protocol fails CI rather than the user's first session.

Clippy: the new crate is held to --all-targets -D warnings; the rest of the
workspace is checked at the same scope the Windows job already uses.
Cross-platform build hygiene turned up by adding a Linux CI job.

Windows-only targets now keep a stub `main` instead of vanishing: gating a bench
or example body with an inner `#![cfg(windows)]` removes its `main` too, and a
bench/example target must always produce an executable. `convert.rs` and
`notepad_hello.rs` gate their bodies and keep a no-op entry point.

Clippy is clean on both targets at the scope CI enforces:
  - collect_matching, not find_all_in_hwnd, is the 9-argument function
  - WindowState::from_str is inherent by design - the Linux engine mirrors the
    name so shared code calls it identically; same for ReflectionBuffer::default
  - the downsample averages now use checked_div, which states the empty-cell case
    (average 0, never a divide by zero) instead of implying it
  - a HashMap value type in the locator cache gets a name
  - ghost-platform's test module moves below the item it was splitting

None of this changes behaviour; the workspace suite stays at 452 passing.
…nder bash

Both fixes come from the first real Linux CI run.

The MCP server refused to start when the accessibility bus was unreachable,
because GhostSession::new eagerly opened the connection. On Windows that is
harmless - COM and UIA are always there. On Linux an absent bus is an ordinary
condition (accessibility off, headless box, service context), and dying at
startup means the client shows "MCP server failed" with no explanation and no
way to ask why.

The bridge now starts its thread immediately and connects on first use. The
server boots, answers tools/list, and the first real call returns the actionable
message naming the gsettings command. A failed connection is not cached, so
enabling accessibility takes effect on the next call rather than after a restart.

Jobs carry a fail arm alongside the run arm, both holding a clone of the same
reply channel, so a caller always receives a real error instead of a dropped
sender surfacing as an opaque "worker disconnected".

The live suite failed for a duller reason: dbus-run-session exec's the script
directly, so with no shebang the kernel used sh, where `set -o pipefail` is
illegal. Added one.

CI already proves the first job of the run: build, unit tests and clippy all
pass on real Linux.
… Windows

Adds scripts/install.sh - the Linux counterpart to kit/install.ps1. It builds,
installs to ~/.local/bin (never sudo), enables toolkit accessibility if it is
off, registers the MCP server with Claude Code idempotently, and finishes by
running `ghost doctor` so a broken setup is reported at install time rather than
in the middle of the first session. It warns about missing runtime packages
instead of failing, because the engine itself needs none of them to build.

Adds .gitattributes pinning *.sh to LF. This is not cosmetic: Ghost is developed
on Windows, and a shell script committed with CRLF fails on Linux with
`bad interpreter: bash^M` - fatal, mystifying, and very easy to do by accident.
The installer is also marked executable in the index, since `git add` from
Windows does not set the mode and `./scripts/install.sh` would otherwise be
permission denied.

README documents Linux: the platform table, install path, requirements, and what
`ghost doctor` checks on each OS.
The live CI run found this: 7 of 9 tests passed, and the 2 that failed both
failed on role="edit".

GTK 3 exposes `zenity --forms`' single-line entry as AT-SPI role `text`, not
`entry`. Toolkits disagree here and the answer varies by version, so matching on
the role name alone is not sound.

The reliable discriminator is the **EditableText interface**: an object that
implements it is, by definition, something an agent can type into. A generic
`text` object that is editable is now normalised to `edit` -- the same name the
Windows engine uses for the same thing -- and `role="edit"` falls back to the
interface regardless of what the toolkit called the widget. A read-only label is
also `text` but is not editable, so it is still correctly excluded.

The normalisation lives in one place (ops::effective_role) and is used by
matching, describe_screen, text extraction and Element::role_name, so a field
found via role="edit" also reports `edit` instead of leaking the toolkit's name.

describe_screen now treats anything editable as actionable, so a text field is
visible to an agent whatever role its toolkit reports.

Live tests gained a roles_in() diagnostic: a failure now prints the roles that
were actually on screen instead of just "not found".
The stub main added for non-Windows targets discarded run()'s Result, which
Windows CI turns into an error via RUSTFLAGS=-D warnings. Verified locally under
the same flag: 452 passing, zero warnings.
sets_and_reads_back_entry_text covers the write path; this covers the action
path. Invoking Cancel through AT-SPI must actually dismiss the dialog, observed
by the window leaving the accessibility tree - a test that only asserted
do_action returned Ok would pass even if the application ignored it.
…e proves

The live AT-SPI suite passes 10/10 on real Linux in CI, against a real GTK
application on a synthesised desktop. That is on-device verification, so
capabilities_for(Linux).functional becomes true - the first time Ghost's rule
has been satisfied for a platform other than Windows.

`supported` lists exactly the six features that suite exercises:
ElementDiscovery, Act, PerActionVerify, BackgroundDispatch, StructuredSnapshot
and Screenshot. KeyInput, EditShortcuts and VisionGrounding are implemented but
deliberately NOT claimed, because nothing has verified them end to end on Linux;
the Wayland portal paths are likewise unclaimed, since CI runs X11. Tests assert
both the presence and the absence, so a future change that quietly widens the
claim fails.

BackgroundDispatch is the one worth stating plainly. The cross-platform doc has
said since the port began that the wedge might degrade off Windows and should be
treated as "unknown -> measure". It was measured: AT-SPI actions make the
application act through its own toolkit, so nothing is raised and no pointer
moves - a cleaner guarantee than posted window messages, not a weaker one. The
docs now say that instead of hedging.

macOS remains a scaffold and claims nothing.
… runs

Two gaps a tester would have hit before any of the engine work mattered.

**The agent-facing schema was wrong.** `ghost_shell` advertised
shell=powershell|pwsh|cmd and described itself as "Run terminal / PowerShell
commands", including a Windows-only recipe for starting a Claude session. An
agent reads that schema to decide what to send, so on Linux every ghost_shell
call it composed would have been wrong before it was sent. The description and
enum are now platform-aware (bash|sh|zsh|pwsh, bash default), and
`ghost_window op=state` says plainly that only `close` works on Linux instead of
letting an agent burn turns on calls that always report Unsupported.

**Nothing a user actually runs had ever been executed on Linux.** The engine had
live tests; the product did not. Added:

- `crates/ghost-mcp/tests/live_mcp_linux.rs` - spawns the real ghost-mcp binary
  and speaks JSON-RPC to it over stdio exactly as Claude Code does, driving a
  real GTK app: tools/list, ghost_window, ghost_see returning real roles,
  ghost_shell running a real command, and a persistent shell keeping a variable
  across sends. Layers can each be right while the whole is not - a wrong
  argument name or an unreadable response envelope only shows up from outside.
- CLI smoke in CI: `ghost doctor` (exit status enforced, since it is the first
  command anyone runs), `ghost list-windows`, `ghost screenshot`.
- An installer job: shellcheck plus actually executing `scripts/install.sh` and
  asserting all three binaries land, so a bad path or a lost +x bit fails here
  rather than on the tester's machine.
- **A Fedora 41 container job** running the whole live suite on the distro the
  docs target, installing exactly the packages docs/linux-fedora.md lists - so
  those instructions are themselves under test.
Caught by the new CI jobs on both Ubuntu and Fedora: `ghost doctor` reported
FAIL when zero applications exposed an accessible tree, and told the user to
enable a setting that was already correct.

Zero windows is ambiguous, and the two readings are indistinguishable from
inside doctor: accessibility may be off so nothing publishes a tree, or nothing
may be open - which is entirely normal on a fresh session. Calling that a
failure means the first command anyone runs on a clean desktop declares the
install broken.

It now WARNs and explains both readings, and only an unreachable bus is fatal.
Tests pin the policy: zero warns, one passes, and only a FAIL sets a non-zero
exit code.

CI now runs doctor with an application open, so the PASS path is what gets
exercised rather than the empty-desktop path, and follows it with
`ghost list-windows` asserting that app appears.
…ording

The doctor fix worked - it now passes with a warning instead of failing - but the
CLI smoke step still failed: zenity got a fixed 3-second head start while the
Rust live tests poll for twenty. Under Xvfb, GTK startup varies by an order of
magnitude between a warm and a cold runner, so the fixed sleep made this job
flaky in a way no user would ever experience.

Both live jobs now poll `ghost list-windows` for the window, and print the full
window list if it never appears so a real failure is diagnosable rather than a
bare non-zero exit.

Also corrects the accessibility message, which still told users to enable a
setting even in the ordinary case of simply having nothing open.
The shell-enum assertion failed because verb descriptions cross-reference each
other, so splitting the raw JSON on "ghost_shell" landed in another tool's prose
rather than the tool definition. It now finds the tool object by name and reads
inputSchema.properties.shell.enum directly, and also asserts the Linux
description does not tell an agent to write PowerShell.

Everything else in the run was green: ghost doctor reported all-PASS with the
app detected, the CLI smoke passed, and the engine live suite was 10/10.
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