computer-use-linux 0.7.18

Linux desktop control over MCP — AT-SPI accessibility tree, multi-compositor window targeting (GNOME, KWin, Hyprland, i3, COSMIC), screencast portal screenshots, and ydotool input synthesis. Wayland-first, X11 best-effort.
Documentation
#!/usr/bin/env python3
"""Opt-in real GTK/MCP regression. Run in an isolated workspace display.

Uses a private bus/config/runtime and disables all physical pointer backends.
Checks actual button activation at GTK scales 1 and 2, not just a response string.
"""
import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import tempfile
import time


def rpc(proc, request):
    proc.stdin.write(json.dumps(request) + '\n')
    proc.stdin.flush()
    if 'id' not in request:
        return
    deadline = time.monotonic() + 25
    while time.monotonic() < deadline:
        if not select.select([proc.stdout], [], [], 1)[0]:
            continue
        line = proc.stdout.readline()
        if not line:
            raise RuntimeError('MCP exited')
        response = json.loads(line)
        if response.get('id') == request['id']:
            assert 'error' not in response, response
            result = response['result']
            assert not result.get('isError'), result
            return result
    raise TimeoutError('MCP response timeout')


def payload(result):
    return result.get('structuredContent') or json.loads(next(c['text'] for c in result['content'] if c['type'] == 'text'))


def main():
    binary = str(Path(sys.argv[1]).resolve())
    if not os.environ.get('CUL_CLICK_PRIVATE_BUS'):
        with tempfile.TemporaryDirectory(prefix='cul-click-test-', dir='/tmp') as tmp:
            env = os.environ.copy()
            env.update(CUL_CLICK_PRIVATE_BUS='1', XDG_CONFIG_HOME=tmp+'/config', XDG_RUNTIME_DIR=tmp+'/runtime', GIO_USE_VFS='local', GVFS_DISABLE_FUSE='1', CU_DISABLE_ABS_POINTER='1', COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER='1', YDOTOOL_SOCKET=tmp+'/no-pointer.sock', XDG_SESSION_TYPE='x11', XDG_CURRENT_DESKTOP='Openbox')
            for key in ('AT_SPI_BUS_ADDRESS', 'WAYLAND_DISPLAY', 'GSETTINGS_BACKEND'):
                env.pop(key, None)
            for folder in ('config', 'runtime'):
                Path(tmp, folder).mkdir(mode=0o700)
            session = subprocess.Popen(['dbus-run-session', '--', '/usr/bin/python3', __file__, binary], env=env, start_new_session=True)
            try:
                assert session.wait(timeout=90) == 0, 'private integration session failed'
            finally:
                # Own the complete test process group, including bus-activated helpers.
                try:
                    os.killpg(session.pid, signal.SIGTERM)
                except ProcessLookupError:
                    pass
                try:
                    session.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    os.killpg(session.pid, signal.SIGKILL)
                    session.wait()
                # The session leader may exit before a descendant. Do not leave
                # a test app that ignored SIGTERM behind in the isolated display.
                try:
                    os.killpg(session.pid, signal.SIGKILL)
                except ProcessLookupError:
                    pass
        return
    subprocess.run(['gsettings', 'set', 'org.gnome.desktop.interface', 'toolkit-accessibility', 'true'], check=True)
    for scale in ('1', '2'):
        with tempfile.TemporaryFile(mode='w+') as evidence:
            code = '''import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib
GLib.set_prgname("cul-semantic-test")
w=Gtk.Window(title="CUL semantic test")
b=Gtk.Button(label="CLICKME")
b.set_size_request(320,120)
b.connect("clicked", lambda *args: print("ACTIVATED", flush=True))
entry=Gtk.Entry()
entry.get_accessible().set_name("ENTRY_TEST")
entry.connect("activate", lambda *args: print("SUBMITTED", flush=True))
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.pack_start(b, True, True, 0)
box.pack_start(entry, False, False, 0)
w.add(box)
w.show_all()
Gtk.main()
'''
            app = mcp = None
            try:
                app = subprocess.Popen(['/usr/bin/python3', '-c', code], env={**os.environ, 'GDK_SCALE':scale}, stdout=evidence)
                mcp = subprocess.Popen([binary, 'mcp'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
                rpc(mcp, {'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2024-11-05','capabilities':{},'clientInfo':{'name':'semantic-click-regression','version':'1'}}})
                rpc(mcp, {'jsonrpc':'2.0','method':'notifications/initialized'})
                for attempt in range(10):
                    state = payload(rpc(mcp, {'jsonrpc':'2.0','id':10+attempt,'method':'tools/call','params':{'name':'get_app_state','arguments':{'pid':app.pid,'include_screenshot':False}}}))
                    nodes = [n for n in state['accessibility_tree'] if n.get('name') == 'CLICKME']
                    if nodes:
                        break
                    time.sleep(.2)
                assert len(nodes) == 1, state
                result = payload(rpc(mcp, {'jsonrpc':'2.0','id':30,'method':'tools/call','params':{'name':'click','arguments':{'element_index':nodes[0]['index']}}}))
                assert result['ok'] and 'AT-SPI action' in result['message'], result
                for attempt in range(20):
                    evidence.seek(0)
                    if evidence.read().strip() == 'ACTIVATED':
                        break
                    time.sleep(.05)
                else:
                    raise AssertionError('Button did not activate')
                print(json.dumps({'scale':scale,'bounds':nodes[0]['bounds'],'activated':True,'message':result['message']}), flush=True)
                blocked = payload(rpc(mcp, {'jsonrpc':'2.0','id':31,'method':'tools/call','params':{'name':'click','arguments':{'x':0,'y':0}}}))
                assert not blocked['ok'], 'physical pointer fallback was not disabled'
                entries = [n for n in state['accessibility_tree'] if n.get('name') == 'ENTRY_TEST']
                assert len(entries) == 1, state
                entry_result = payload(rpc(mcp, {'jsonrpc':'2.0','id':32,'method':'tools/call','params':{'name':'click','arguments':{'element_index':entries[0]['index']}}}))
                assert not entry_result['ok'], 'entry click bypassed the disabled pointer path'
                time.sleep(.1)
                evidence.seek(0)
                assert evidence.read().strip() == 'ACTIVATED', 'entry click submitted the form'
            finally:
                for proc in (mcp, app):
                    if proc is None:
                        continue
                    proc.terminate()
                    try:
                        proc.wait(timeout=5)
                    except subprocess.TimeoutExpired:
                        proc.kill()
                        proc.wait()


if __name__ == '__main__':
    main()