Skip to content

Commit 298c4c4

Browse files
committed
release: v0.3.0 — input-landing feedback, off-screen detection, window geometry tools
Fixes a set of agent papercuts found while driving a live desktop: - AT-SPI connect no longer fails when an AppArmor-confined snap app (e.g. Slack) sits on the a11y bus: the atspi crate's default "p2p" feature enumerates every peer during connect and one denied GetInterfaces call aborted the whole AccessibilityConnection. Depend on atspi-connection directly with p2p off (we never use P2P routing; see #31). - Targeted press_key/type_text results now append focused-element feedback (role, name, editable) read back from AT-SPI after the input, and warn when no editable element holds focus — previously keystrokes could land nowhere while the result reported verified window focus. - Screenshot, click, and targeted input results warn when the target window or coordinate is partially or fully off-screen. Monitor layout comes from the GNOME Shell extension (logical space, used for window bounds) or the captured frame size (physical space, used for click coordinates) — the two spaces are never mixed, so fractional scaling cannot false-positive. - New move_window / resize_window tools (GNOME Shell extension v2, which also gains GetMonitorLayout) to recover off-screen windows. Old installed extensions degrade with an explicit "rerun setup_window_targeting and re-login" hint. resize_window unmaximizes first, handling both the pre-49 (get_maximized + MaximizeFlags) and 49+ (is_maximized, flagless unmaximize) mutter APIs. - get_app_state now returns a compact readiness block by default and the full diagnostics report only with verbose=true; failures point to the flag instead of dumping portal/process diagnostics every call. - scroll gains click-parity window targeting and window-relative coordinates; press_key documents its full key grammar in the tool description. - Accessibility errors surface their full anyhow chain ({error:#}) instead of the outermost context line. - cargo update for RUSTSEC-2026-0190 (anyhow); justified cargo-audit ignores for the build-time-only quick-xml advisories pulled in via wayland-scanner (RUSTSEC-2026-0194/0195).
1 parent f9b17ba commit 298c4c4

13 files changed

Lines changed: 1017 additions & 70 deletions

File tree

.cargo/audit.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# cargo-audit configuration.
2+
#
3+
# Ignored advisories must carry a justification and an unignore condition.
4+
[advisories]
5+
ignore = [
6+
# RUSTSEC-2026-0194 / RUSTSEC-2026-0195: quick-xml 0.39.x DoS issues
7+
# (quadratic dup-attribute check, unbounded namespace allocation).
8+
# quick-xml is reachable only through wayland-scanner, a proc-macro that
9+
# parses the *vendored* Wayland protocol XML at build time; it never
10+
# touches attacker-controlled input and is absent from the shipped binary.
11+
# Unignore when wayland-scanner releases with quick-xml >= 0.41
12+
# (https://github.com/Smithay/wayland-rs — check `cargo update -p
13+
# wayland-scanner && cargo tree -i quick-xml`).
14+
"RUSTSEC-2026-0194",
15+
"RUSTSEC-2026-0195",
16+
]

Cargo.lock

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "computer-use-linux"
3-
version = "0.2.10"
3+
version = "0.3.0"
44
edition = "2021"
55
authors = ["Avi Fenesh <avifenesh@users.noreply.github.com>"]
66
license = "MIT"
@@ -23,7 +23,14 @@ path = "src/bin/computer-use-linux-cosmic.rs"
2323
[dependencies]
2424
mimalloc = { version = "0.1", default-features = false }
2525
anyhow = "1.0.102"
26-
atspi = { version = "0.29.0", features = ["tokio"] }
26+
# atspi without its "connection" feature: that feature pulls atspi-connection
27+
# with default features, which include "p2p" — and p2p peer enumeration during
28+
# connect() calls GetInterfaces on every a11y-bus peer, so a single
29+
# AppArmor-confined peer (e.g. snap-packaged Slack) aborts the whole
30+
# AccessibilityConnection. We deliberately never use P2P routing (issue #31),
31+
# so depend on atspi-connection directly with p2p off.
32+
atspi = { version = "0.29.0", default-features = false, features = ["proxies", "wrappers", "tokio"] }
33+
atspi-connection = { version = "0.13.0", default-features = false, features = ["wrappers"] }
2734
base64 = "0.22.1"
2835
evdev = "0.13.2"
2936
cosmic-protocols = { version = "0.2.0", default-features = false, features = ["client"] }

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@ Screenshot payloads are size-bounded by default before they are returned to the
5656
- `press_key` — keys / chords; can focus a window or terminal first
5757
- `type_text` — literal text input, optionally targeted at a window or terminal
5858

59+
Targeted `press_key`/`type_text` results append focused-element feedback from AT-SPI (role, name, editable) and warn when no editable element holds focus. Click/screenshot/input results warn when the target window or coordinate is partially or fully off-screen. `get_app_state` returns a compact readiness block by default; pass `verbose: true` for the full diagnostics report.
60+
5961
**Semantic actions**
6062
- `perform_action` — invoke any AT-SPI action exposed by an element (`Press`, `Activate`, `Toggle`, …); defaults to the primary action
6163
- `set_value` — write to a settable accessibility element (text fields, sliders, spinners)
6264

6365
**Navigation**
6466
- `activate_window` — focus a window by `window_id`, `pid`, `app_id`, `wm_class`, `title`, or terminal selectors
67+
- `move_window` / `resize_window` — reposition or resize a window in desktop coordinates (GNOME Shell extension backend); useful to recover windows that are partially off-screen
6568

6669
### MCP safety contract
6770

@@ -71,7 +74,7 @@ Screenshot payloads are size-bounded by default before they are returned to the
7174
| --- | --- | --- |
7275
| Read-only observation | `doctor`, `list_apps`, `list_windows`, `focused_window`, `get_app_state` | `readOnlyHint=true`; may reveal app, window, accessibility, and screenshot contents. `get_app_state` may trigger the desktop screenshot portal prompt. |
7376
| Local setup mutators | `setup_accessibility`, `setup_window_targeting` | `readOnlyHint=false`, `destructiveHint=false`, `idempotentHint=true`; modifies user desktop configuration by enabling accessibility or installing/enabling the GNOME window-targeting extension. |
74-
| UI state mutators | `activate_window`, `scroll`, `screenshot` | `readOnlyHint=false`, `destructiveHint=false`; changes focus or scroll position in the live desktop, or raises a window to capture it. |
77+
| UI state mutators | `activate_window`, `move_window`, `resize_window`, `scroll`, `screenshot` | `readOnlyHint=false`, `destructiveHint=false`; changes focus, geometry, or scroll position in the live desktop, or raises a window to capture it. |
7578
| Desktop action mutators | `click`, `drag`, `press_key`, `type_text`, `perform_action`, `set_value` | `readOnlyHint=false`, `destructiveHint=true`, `openWorldHint=true`; can trigger arbitrary actions in whatever local application is targeted. |
7679

7780
Annotations are safety hints, not an authorization system. MCP hosts should still ask the user before calls that could submit, delete, send, purchase, overwrite, or otherwise commit state.

examples/atspi_probe.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Scratch probe: surface the real AT-SPI connection error chain.
2+
use atspi_connection::AccessibilityConnection;
3+
4+
#[tokio::main]
5+
async fn main() {
6+
println!(
7+
"AT_SPI_BUS_ADDRESS={:?}",
8+
std::env::var("AT_SPI_BUS_ADDRESS")
9+
);
10+
println!(
11+
"DBUS_SESSION_BUS_ADDRESS={:?}",
12+
std::env::var("DBUS_SESSION_BUS_ADDRESS")
13+
);
14+
match AccessibilityConnection::new().await {
15+
Ok(conn) => {
16+
println!("connected OK");
17+
match conn.root_accessible_on_registry().await {
18+
Ok(root) => match root.get_children().await {
19+
Ok(children) => println!("registry children: {}", children.len()),
20+
Err(e) => println!("get_children error: {e:#?}"),
21+
},
22+
Err(e) => println!("registry root error: {e:#?}"),
23+
}
24+
}
25+
Err(e) => println!("connect error: {e:#?}"),
26+
}
27+
}

gnome-shell-extension/computer-use-linux@avifenesh.dev/extension.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ const WINDOW_CONTROL_XML = `
2222
<arg name="ok" type="b" direction="out"/>
2323
<arg name="message" type="s" direction="out"/>
2424
</method>
25+
<method name="MoveWindow">
26+
<arg name="window_id" type="t" direction="in"/>
27+
<arg name="x" type="i" direction="in"/>
28+
<arg name="y" type="i" direction="in"/>
29+
<arg name="ok" type="b" direction="out"/>
30+
<arg name="message" type="s" direction="out"/>
31+
</method>
32+
<method name="ResizeWindow">
33+
<arg name="window_id" type="t" direction="in"/>
34+
<arg name="width" type="i" direction="in"/>
35+
<arg name="height" type="i" direction="in"/>
36+
<arg name="ok" type="b" direction="out"/>
37+
<arg name="message" type="s" direction="out"/>
38+
</method>
39+
<method name="GetMonitorLayout">
40+
<arg name="json" type="s" direction="out"/>
41+
</method>
2542
</interface>
2643
</node>
2744
`;
@@ -89,6 +106,71 @@ class WindowControlDBus extends GObject.Object {
89106
}
90107
}
91108

109+
MoveWindowAsync([windowId, x, y], invocation) {
110+
this._withWindow(windowId, invocation, window => {
111+
// move_frame positions the frame rect (what list_windows reports).
112+
window.move_frame(true, x, y);
113+
return `Moved window_id ${Number(windowId)} to ${x},${y}`;
114+
});
115+
}
116+
117+
ResizeWindowAsync([windowId, width, height], invocation) {
118+
this._withWindow(windowId, invocation, window => {
119+
if (width <= 0 || height <= 0)
120+
throw new Error(`invalid size ${width}x${height}`);
121+
// GNOME 49 removed Meta.Window.get_maximized() (use is_maximized())
122+
// and dropped the flags argument from unmaximize(). Support both
123+
// API generations: shell 45-48 (get_maximized + flags) and 49+.
124+
if (window.is_maximized?.())
125+
window.unmaximize();
126+
else if (window.get_maximized?.())
127+
window.unmaximize(Meta.MaximizeFlags.BOTH);
128+
const rect = window.get_frame_rect();
129+
window.move_resize_frame(true, rect.x, rect.y, width, height);
130+
return `Resized window_id ${Number(windowId)} to ${width}x${height}`;
131+
});
132+
}
133+
134+
GetMonitorLayoutAsync(_params, invocation) {
135+
const monitors = Main.layoutManager.monitors.map(monitor => ({
136+
index: monitor.index,
137+
x: monitor.x,
138+
y: monitor.y,
139+
width: monitor.width,
140+
height: monitor.height,
141+
primary: monitor.index === Main.layoutManager.primaryIndex,
142+
scale: monitor.geometry_scale ?? 1,
143+
}));
144+
this._returnJson(invocation, monitors);
145+
}
146+
147+
_withWindow(windowId, invocation, action) {
148+
const requestedId = Number(windowId);
149+
const window = this._listMetaWindows().find(
150+
candidate => Number(candidate.get_id()) === requestedId);
151+
152+
if (!window) {
153+
invocation.return_value(new GLib.Variant('(bs)', [
154+
false,
155+
`No window matched window_id ${requestedId}`,
156+
]));
157+
return;
158+
}
159+
160+
try {
161+
const message = action(window);
162+
invocation.return_value(new GLib.Variant('(bs)', [
163+
true,
164+
message,
165+
]));
166+
} catch (error) {
167+
invocation.return_value(new GLib.Variant('(bs)', [
168+
false,
169+
`Window operation failed: ${error.message}`,
170+
]));
171+
}
172+
}
173+
92174
_returnJson(invocation, value) {
93175
invocation.return_value(new GLib.Variant('(s)', [
94176
JSON.stringify(value),

gnome-shell-extension/computer-use-linux@avifenesh.dev/metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"49",
1111
"50"
1212
],
13-
"version": 1
14-
}
13+
"version": 2
14+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agent-sh/computer-use-linux",
3-
"version": "0.2.10",
3+
"version": "0.3.0",
44
"description": "Linux desktop-control MCP server: AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting.",
55
"license": "MIT",
66
"type": "commonjs",

scripts/mcp_safety_check.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"focused_window",
2424
"screenshot",
2525
"activate_window",
26+
"move_window",
27+
"resize_window",
2628
"click",
2729
"drag",
2830
"scroll",
@@ -103,6 +105,8 @@
103105
"setup_accessibility",
104106
"setup_window_targeting",
105107
"activate_window",
108+
"move_window",
109+
"resize_window",
106110
}
107111

108112
OPEN_WORLD_TOOLS = EXPECTED_TOOLS - {

src/atspi_tree.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ use atspi::{
55
accessible::{AccessibleProxy, ObjectRefExt},
66
proxy_ext::ProxyExt,
77
},
8-
AccessibilityConnection, CoordType, ObjectRef, ObjectRefOwned, StateSet,
8+
CoordType, ObjectRef, ObjectRefOwned, StateSet,
99
};
10+
// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
11+
// atspi's "connection" re-export.
12+
use atspi_connection::AccessibilityConnection;
1013
use schemars::JsonSchema;
1114
use serde::Serialize;
1215
use std::collections::VecDeque;
@@ -157,6 +160,67 @@ pub async fn snapshot_tree(
157160
Ok(nodes)
158161
}
159162

163+
/// Compact description of the AT-SPI element that currently holds keyboard
164+
/// focus, used as post-input feedback for type_text/press_key.
165+
#[derive(Debug, Clone, Serialize, JsonSchema)]
166+
pub struct FocusedElementSummary {
167+
pub role: String,
168+
pub name: Option<String>,
169+
pub editable: bool,
170+
pub states: Vec<String>,
171+
}
172+
173+
const FOCUS_PROBE_MAX_NODES: usize = 400;
174+
const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
175+
176+
/// Find the element with the `focused` state inside the target app (by pid) or
177+
/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
178+
/// element is reachable through AT-SPI (common for apps without accessibility
179+
/// support, e.g. Electron without --force-renderer-accessibility).
180+
pub async fn focused_element_summary(
181+
target_pid: Option<u32>,
182+
) -> Result<Option<FocusedElementSummary>> {
183+
let conn = connect().await?;
184+
let roots = registry_children(&conn).await?;
185+
let selected_roots = select_roots(&conn, roots, None, target_pid).await;
186+
let mut visited = 0_usize;
187+
let mut queue = VecDeque::new();
188+
189+
for object_ref in selected_roots {
190+
queue.push_back((object_ref, 0_u32));
191+
}
192+
193+
while let Some((object_ref, depth)) = queue.pop_front() {
194+
if visited >= FOCUS_PROBE_MAX_NODES {
195+
break;
196+
}
197+
visited += 1;
198+
199+
let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
200+
continue;
201+
};
202+
let Ok(state) = proxy.get_state().await else {
203+
continue;
204+
};
205+
if state.contains(atspi::State::Focused) {
206+
let proxies = proxy.proxies().await.ok();
207+
return Ok(Some(FocusedElementSummary {
208+
role: role_name(&proxy).await,
209+
name: optional_string(proxy.name().await.ok()),
210+
editable: supports_editable_text(proxies.as_ref()).await,
211+
states: state_labels(state),
212+
}));
213+
}
214+
if depth < FOCUS_PROBE_MAX_DEPTH {
215+
for child in proxy.get_children().await.unwrap_or_default() {
216+
queue.push_back((child, depth + 1));
217+
}
218+
}
219+
}
220+
221+
Ok(None)
222+
}
223+
160224
pub async fn perform_action(
161225
object_ref_id: &str,
162226
requested_action: Option<&str>,

0 commit comments

Comments
 (0)