mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-15 03:34:59 +00:00
* feat(cua-driver): vendor trycua/cua driver with 1000-normalized coordinate support
Vendor libs/cua-driver from trycua/cua into packages/cua-driver as the
basis for qwen-code's computer-use backend, adding an opt-in relative
(1000x1000 normalized) coordinate mode for Qwen-VL clients.
- coord_norm.rs: 0-1000 <-> pixel conversion, per-(pid,window_id) size
cache, tools/list description rewrite (TDD, 27 tests)
- ToolRegistry: normalized field + invoke input/output hooks
- protocol.rs: system-instruction coordinate wording switched by mode
- serve.rs: daemon list path description rewrite (input_schema aware)
- main.rs: CUA_DRIVER_RS_COORDINATE_SPACE env seed
Default coordinate_space=pixels => zero behavior change for existing
pixel clients. Set CUA_DRIVER_RS_COORDINATE_SPACE=normalized_1000 to
enable. Excludes rust/target build output.
* feat(cua-driver): make normalized coordinate scale configurable
Add CUA_DRIVER_RS_COORDINATE_SCALE (default 1000) so the normalization
full-scale can absorb the Qwen 999-vs-1000 cookbook ambiguity without a
recompile. norm_to_px/px_to_norm now take an explicit scale; denormalize_args
reads the process-wide COORDINATE_SCALE seeded once at startup from env.
* ci(cua-driver): add cross-platform release workflow for vendored driver
Standalone GitHub Action that builds, signs, and releases the vendored
cua-driver under packages/cua-driver. Adapted from upstream trycua/cua
cd-rust-cua-driver.yml:
- macOS: universal binary (lipo arm64+x86_64), codesigned + notarized into
CuaDriver.app using qwen-code's existing secrets (MAC_CSC_LINK cert +
App Store Connect API key notarization); Developer ID identity is
auto-discovered from the imported cert.
- Linux: x86_64 + arm64, built in debian:11 for a glibc 2.31 floor.
- Windows: x86_64 + arm64, unsigned (no EV cert, matches upstream).
- Release: softprops/action-gh-release on cua-driver-rs-v* tags or manual
dispatch, prerelease.
Triggered by tag push (cua-driver-rs-v*) or workflow_dispatch.
* chore(cua-driver): rebrand vendored driver as qwen-cua-driver
Rename the vendored trycua/cua driver so the fork installs and runs
independently of any upstream trycua install:
- binary cua-driver -> qwen-cua-driver
- bundle CuaDriver.app -> QwenCuaDriver.app
- bundle id com.trycua.driver -> com.qwencode.cua-driver
Updates the cargo/uia manifests, Info.plist, bundle/proxy launch paths,
permission/health-report wording, the install/build scripts, and the
cross-platform release workflow.
* feat(cua-driver): finish relative-coordinate mode — toggle, scale, zoom/move_cursor
- CUA_DRIVER_RS_COORDINATE_SPACE is now a 1/0 toggle (via is_env_truthy);
default off keeps pixel mode byte-identical to upstream.
- Thread CUA_DRIVER_RS_COORDINATE_SCALE through every coordinate surface
(was hardcoded 1000): input denormalization already used it; now the
rewritten screenshot dims, the tool/param descriptions, and the agent
instructions track the configured scale too.
- Normalize zoom (window basis) and move_cursor (screen basis) inputs and
rewrite their descriptions, alongside click/double_click/right_click/drag.
- Fix zoom on downscaled (Retina) windows: apply the get_window_state resize
ratio so the crop lands on the region the agent saw. Normalized mode only;
pixel-mode zoom unchanged.
All coordinate behavior stays gated on the normalized flag, so the default
(pixels) path is unchanged from upstream.
* chore(cua-driver): add upstream-sync script (git subtree unusable here)
`git subtree split --prefix=libs/cua-driver` hangs on a commit deep in
trycua/cua's history, so the subtree add/pull workflow isn't usable for
the vendored driver (and a pull would re-split + re-hang every time).
Add scripts/sync-from-upstream.sh instead: it git-diffs two upstream refs
(never walks the full history, so it dodges the hang), reprefixes the
libs/cua-driver delta to packages/cua-driver, and `git apply --reject`s it
on top of our local changes — conflicts land as *.rej for manual fixup.
Record the vendored version in .vendored-from and document the migration +
sync method in the design doc.
* chore(cua-driver): exclude vendored driver from qwen-code ESLint
The vendored packages/cua-driver tree carries upstream JS (e.g. the
test-harness Electron app) that doesn't follow qwen-code's lint rules and
fails CI. It is not a workspace package (no package.json) and is not
qwen-code TypeScript, so add it to eslint.config.js global ignores —
alongside packages/desktop/** — the standard treatment for vendored code.
* fix(cua-driver): let start_session revive an idle-reaped session
Ports the fix from upstream trycua/cua#2035 into the vendored driver.
When a session is reaped for idleness, a subsequent start_session with the
same id failed instead of resuming it. Revive the ended session in place so
the agent can continue rather than getting a hard error.
* fix(cua-driver): retry daemon socket writes on EAGAIN
Ports the fix from upstream trycua/cua#2036 into the vendored driver.
A non-blocking daemon socket can return EAGAIN/EWOULDBLOCK mid-write when the
peer's receive buffer is momentarily full. The driver treated that as fatal
and dropped the connection. Add a bounded retry/poll loop (mirror of the
read-side socket_io helper) so transient back-pressure no longer kills the
session; only a real timeout or hard error fails the write.
* fix(cua-driver/linux): stop reporting bare "Clicked" for X11 synthetic clicks
Ports the fix from upstream trycua/cua#2025 into the vendored driver.
On X11, clicks are delivered via XSendEvent synthetic events, which many
toolkits (GTK/SDL/Allegro) ignore because send_event is set. The driver still
reported a flat success ("Clicked"), masking that nothing happened. Report
the synthetic-delivery caveat honestly so the agent can fall back instead of
assuming the click landed.
(platform-linux crate is not built on macOS; verified by clean upstream apply
and covered by upstream + release-workflow Linux CI.)
* fix(cua-driver/windows): list empty-/null-title top-level windows
Ports the fix from upstream trycua/cua#2021 into the vendored driver.
list_windows filtered out any top-level window whose title was empty or null,
so legitimate targets (splash screens, some Electron/game windows, tool
windows) were invisible to the agent and unclickable. Include empty-title
windows, using class name / process as a fallback label.
(platform-windows crate is not built on macOS; verified by clean upstream
apply and covered by upstream + release-workflow Windows CI.)
* chore(cua-driver): track cherry-picked upstream PRs; fix vendored-from
The vendored copy is actually at cua-driver-rs-v0.6.7 (workspace version and
all 0.6.7->0.6.8 delta files confirm it), but .vendored-from had drifted to
0.6.8 during an earlier sync-script trial whose code delta was not kept. Left
as-is it would make a future sync diff 0.6.8->newer and silently skip the real
0.6.7->0.6.8 fixes. Correct it back to 0.6.7.
Also record the four not-yet-merged upstream PRs we carry as cherry-picks
(trycua/cua#2021/#2025/#2035/#2036) in .vendored-patches.md, and have
sync-from-upstream.sh point at it so the next sync reconciles them.
* ci(cua-driver): satisfy repo yamllint on the release workflow
The vendored-driver release workflow tripped 114 quoted-strings violations
under the repo's .yamllint (quote-type: single, required). Single-quote all
string scalars to match every other workflow in .github/workflows.
While reformatting, the release-notes body also got its paragraph blank lines
collapsed and still referenced the old CUA_DRIVER_RS_COORDINATE_SPACE=
normalized_1000 value — restore the blank lines and update it to the current
0/1 toggle (default 0 = off; optional CUA_DRIVER_RS_COORDINATE_SCALE=1000).
* chore(cua-driver): sync vendored driver to cua-driver-rs-v0.6.8
First real run of scripts/sync-from-upstream.sh: it 3-way-applied the upstream
0.6.7->0.6.8 delta onto our local fork. 10/12 files applied cleanly; the 2
rejects (install.ps1, _install-rust.sh) were already-applied baked-version
bumps (0.6.6->0.6.7, our copies were already at 0.6.7), i.e. no real conflict.
0.6.8 brings: Wayland input path (platform-linux), linux health_report +
overlay tweaks, a platform-macos build.rs step, and dependency bumps. Version
moved to 0.6.8 across the workspace.
Verified our work survived the sync untouched: the relative-coordinate shim
(coord_norm/protocol) and all four cherry-picked PRs (socket_io/session +
linux/windows) are intact — in particular the 0.6.8 edit to platform-linux
tools/impl_.rs landed alongside our #2025 change with no collision. macOS
cargo check + 132 core tests green. (platform-linux/windows + the binary
integration test build only on their own runners; upstream CI covers those.)
* ci(cua-driver): add a dry_run gate to the release workflow
Mirror the desktop-release / release dry-run pattern: a workflow_dispatch
dry_run boolean input (default true). The cross-platform build + package jobs
always run and upload their artifacts; the GitHub Release job now publishes
only on a tag push or an explicit dry_run=false dispatch.
Lets us rehearse the whole build/package pipeline (dry_run=true, notarize=false)
and inspect the produced artifacts without cutting a release. A branch push
(no tag, not a dispatch) likewise builds without releasing.
242 lines
9.5 KiB
Python
242 lines
9.5 KiB
Python
"""Integration test: cua-driver-rs CLI subcommands.
|
|
|
|
Covers:
|
|
1. `list-tools` prints tool names and summaries.
|
|
2. `describe <tool>` prints the tool schema.
|
|
3. `call <tool> [json]` invokes a tool and prints ✅ result.
|
|
4. Implicit call (first positional as tool name) works.
|
|
5. Unknown tool exits 64.
|
|
6. Error tool result exits 1.
|
|
7. `screenshot` call returns JSON with a screenshot_png_b64 key.
|
|
|
|
Run:
|
|
CUA_DRIVER_BINARY=../../target/debug/cua-driver python3 -m unittest test_cli -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from driver_client import default_binary_path
|
|
|
|
BINARY = None # set in setUpClass
|
|
|
|
|
|
def _run(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
class CLISubcommandTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.binary = default_binary_path()
|
|
|
|
def test_list_tools_prints_tool_names(self) -> None:
|
|
r = _run([self.binary, "list-tools"])
|
|
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
self.assertIn("click", r.stdout)
|
|
self.assertIn("screenshot", r.stdout)
|
|
self.assertIn("list_windows", r.stdout)
|
|
self.assertIn("press_key", r.stdout)
|
|
|
|
def test_list_tools_has_name_colon_summary_format(self) -> None:
|
|
r = _run([self.binary, "list-tools"])
|
|
self.assertEqual(r.returncode, 0)
|
|
for line in r.stdout.strip().splitlines():
|
|
self.assertRegex(
|
|
line,
|
|
r"^\w+",
|
|
f"list-tools line should start with tool name: {line!r}",
|
|
)
|
|
|
|
def test_describe_prints_schema(self) -> None:
|
|
r = _run([self.binary, "describe", "click"])
|
|
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
self.assertIn("click", r.stdout)
|
|
self.assertIn("pid", r.stdout)
|
|
self.assertIn("input_schema", r.stdout)
|
|
|
|
def test_describe_unknown_tool_exits_64(self) -> None:
|
|
r = _run([self.binary, "describe", "no_such_tool_xyz"])
|
|
self.assertEqual(r.returncode, 64)
|
|
self.assertIn("no_such_tool_xyz", r.stderr)
|
|
|
|
def test_call_get_screen_size(self) -> None:
|
|
r = _run([self.binary, "call", "get_screen_size"])
|
|
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
|
|
data = json.loads(r.stdout)
|
|
self.assertIn("width", data)
|
|
self.assertIn("height", data)
|
|
self.assertGreater(data["width"], 0)
|
|
self.assertGreater(data["height"], 0)
|
|
|
|
def test_implicit_call_get_screen_size(self) -> None:
|
|
"""First positional arg is treated as tool name (no 'call' prefix)."""
|
|
r = _run([self.binary, "get_screen_size"])
|
|
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
|
|
data = json.loads(r.stdout)
|
|
self.assertIn("width", data)
|
|
self.assertIn("height", data)
|
|
|
|
def test_call_check_permissions_exits_zero(self) -> None:
|
|
"""check_permissions is read-only and always exits 0."""
|
|
r = _run([self.binary, "call", "check_permissions"])
|
|
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
|
|
|
|
def test_call_check_permissions_output_has_green_checkmark(self) -> None:
|
|
r = _run([self.binary, "call", "check_permissions"])
|
|
self.assertEqual(r.returncode, 0)
|
|
# The structured JSON output should have accessibility/screen_recording fields.
|
|
data = json.loads(r.stdout)
|
|
self.assertIn("accessibility", data)
|
|
self.assertIn("screen_recording", data)
|
|
|
|
def test_call_unknown_tool_exits_64(self) -> None:
|
|
r = _run([self.binary, "call", "no_such_tool_xyz"])
|
|
self.assertEqual(r.returncode, 64)
|
|
|
|
def test_call_press_key_with_missing_pid_exits_1(self) -> None:
|
|
"""Calling a tool with missing required args should return an MCP error → exit 1."""
|
|
r = _run([self.binary, "call", "press_key", '{"key": "a"}'])
|
|
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}\nstderr: {r.stderr}")
|
|
|
|
def test_call_list_apps_json_output(self) -> None:
|
|
r = _run([self.binary, "call", "list_apps"])
|
|
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
data = json.loads(r.stdout)
|
|
self.assertIn("apps", data)
|
|
self.assertIsInstance(data["apps"], list)
|
|
self.assertGreater(len(data["apps"]), 0)
|
|
|
|
def test_call_screenshot_returns_b64_image(self) -> None:
|
|
r = _run([self.binary, "call", "screenshot"], timeout=30)
|
|
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
data = json.loads(r.stdout)
|
|
self.assertIn("screenshot_png_b64", data, "screenshot output missing image key")
|
|
b64 = data["screenshot_png_b64"]
|
|
self.assertGreater(len(b64), 100, "screenshot base64 data seems too short")
|
|
|
|
|
|
class ServeDaemonTests(unittest.TestCase):
|
|
"""Tests for `cua-driver serve` / `stop` / `status`."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.binary = default_binary_path()
|
|
import tempfile, os
|
|
cls._sock_file = tempfile.mktemp(suffix=".sock", prefix="cua-driver-test-")
|
|
|
|
def _start_daemon(self):
|
|
import subprocess, time
|
|
proc = subprocess.Popen(
|
|
[self.binary, "serve", "--socket", self._sock_file],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
)
|
|
# Wait for daemon to bind.
|
|
for _ in range(30):
|
|
time.sleep(0.1)
|
|
r = _run([self.binary, "status", "--socket", self._sock_file])
|
|
if r.returncode == 0:
|
|
break
|
|
return proc
|
|
|
|
def test_status_exits_1_when_no_daemon(self) -> None:
|
|
import tempfile
|
|
sock = tempfile.mktemp(suffix=".sock", prefix="cua-noexist-")
|
|
r = _run([self.binary, "status", "--socket", sock])
|
|
self.assertEqual(r.returncode, 1)
|
|
self.assertIn("not running", r.stderr)
|
|
|
|
def test_stop_exits_1_when_no_daemon(self) -> None:
|
|
import tempfile
|
|
sock = tempfile.mktemp(suffix=".sock", prefix="cua-noexist2-")
|
|
r = _run([self.binary, "stop", "--socket", sock])
|
|
self.assertEqual(r.returncode, 1)
|
|
|
|
def test_serve_status_stop_lifecycle(self) -> None:
|
|
"""Start daemon → status is running → stop → status is not running."""
|
|
proc = self._start_daemon()
|
|
try:
|
|
# Status should show running.
|
|
r = _run([self.binary, "status", "--socket", self._sock_file])
|
|
self.assertEqual(r.returncode, 0, f"status stderr: {r.stderr}")
|
|
self.assertIn("running", r.stdout)
|
|
self.assertIn("socket:", r.stdout)
|
|
|
|
# Stop the daemon.
|
|
r = _run([self.binary, "stop", "--socket", self._sock_file])
|
|
self.assertEqual(r.returncode, 0, f"stop stderr: {r.stderr}")
|
|
|
|
# Status should now show not running.
|
|
import time; time.sleep(0.2)
|
|
r = _run([self.binary, "status", "--socket", self._sock_file])
|
|
self.assertEqual(r.returncode, 1)
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait(timeout=3)
|
|
|
|
def test_serve_double_start_exits_1(self) -> None:
|
|
"""Second `serve` on same socket should fail with exit 1."""
|
|
proc = self._start_daemon()
|
|
try:
|
|
r = _run([self.binary, "serve", "--socket", self._sock_file])
|
|
self.assertEqual(r.returncode, 1)
|
|
self.assertIn("already running", r.stderr)
|
|
finally:
|
|
_run([self.binary, "stop", "--socket", self._sock_file])
|
|
proc.wait(timeout=3)
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"Windows: daemon spawned by subprocess.Popen runs in same session as "
|
|
"the test runner (typically Session 0 / non-interactive in CI); the "
|
|
"GDI BitBlt path fails with `The handle is invalid (0x80070006)` "
|
|
"because Session 0 has no graphics. The regression-guard behaviour "
|
|
"still verifies on macOS/Linux runners.",
|
|
)
|
|
def test_call_screenshot_via_daemon_emits_b64(self) -> None:
|
|
"""`cua-driver call screenshot` over the daemon socket must emit
|
|
`screenshot_png_b64` — same shape as the in-process path. Regression
|
|
guard for the 2026-05-23 fix where the daemon-forwarding path in
|
|
`run_call` was silently dropping the image bytes (only printing
|
|
structuredContent metadata: format / width / height).
|
|
"""
|
|
proc = self._start_daemon()
|
|
try:
|
|
r = _run(
|
|
[self.binary, "call", "--socket", self._sock_file, "screenshot"],
|
|
timeout=30,
|
|
)
|
|
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
data = json.loads(r.stdout)
|
|
self.assertIn(
|
|
"screenshot_png_b64", data,
|
|
"daemon-forwarded screenshot dropped the image — "
|
|
"merge into structuredContent regressed",
|
|
)
|
|
b64 = data["screenshot_png_b64"]
|
|
self.assertGreater(
|
|
len(b64), 100,
|
|
"screenshot base64 data seems too short — likely empty payload",
|
|
)
|
|
self.assertIn(
|
|
"screenshot_mime_type", data,
|
|
"merge into structuredContent missed the mime-type key",
|
|
)
|
|
finally:
|
|
_run([self.binary, "stop", "--socket", self._sock_file])
|
|
proc.wait(timeout=3)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|