diff --git a/tools/w7/README.md b/tools/w7/README.md index 472278a3f4c070ed70faa66109c86b878b5aa601..33d220c1147273ec762bc3ee0b87993f938948df 100644 --- a/tools/w7/README.md +++ b/tools/w7/README.md @@ -163,6 +163,25 @@ clients. A local VDE switch joins up to 63 guests without host privileges. The `one-linux` MCP exposes fetch, up, SSH, down, and status; its `up` and `down` follow the same auto-create, wait-flag, and preserve conventions as Windows. +## Linux desktop VM + +`up --desktop` adds an X display for driving GUI apps: Xvfb `:0` at 1280x800 +under openbox, run as lingering systemd user units, with xdotool, xclip, maim, +AT-SPI and Mesa's software Vulkan (lavapipe) for wgpu. There is no guest agent; +each MCP call pipes [linux_desktop.py](linux_desktop.py) into `python3 -` over +SSH. + +```sh +./linux_vm.py up desk --desktop --cpus 8 --memory 8192 --disk 40 --wait +./linux_vm.py ssh desk -- 'xdotool getmouselocation' +``` + +`linux_exec` runs a bash script and returns output plus a screenshot, the +counterpart of `win7_exec` with xdotool in place of AutoHotkey. `linux_shot`, +`linux_ui` (AT-SPI tree), `linux_spawn` (transient user unit), `linux_put` and +`linux_get` mirror the Windows tools. A desktop clone is still a Samba +appliance, so it shares the one-at-a-time lab address rule. + To restore a base from private object storage, upload the sealed qcow2 beside its JSON manifest and pass the manifest URL. A presigned URL needs no secret; otherwise set `ONE_VM_AUTHORIZATION` to the complete HTTP Authorization value. diff --git a/tools/w7/linux_desktop.py b/tools/w7/linux_desktop.py new file mode 100644 index 0000000000000000000000000000000000000000..c746635346c1f23a4aba1853bb7746f8917b444c --- /dev/null +++ b/tools/w7/linux_desktop.py @@ -0,0 +1,120 @@ +"""Guest half of the one-linux desktop tools. + +mcp_linux.py pipes this source plus a `main(request)` call into `python3 -` over +SSH, so the guest needs no installed agent and always runs the current code. +""" + +import base64 +import json +import os +import signal +import struct +import subprocess +import tempfile +import time + +UI_LINE_LIMIT = 1500 + + +def screen(): + png = subprocess.run(["maim", "--hidecursor"], capture_output=True, check=True).stdout + width, height = struct.unpack(">II", png[16:24]) + return {"png_b64": base64.b64encode(png).decode("ascii"), "w": width, "h": height} + + +def output(*argv): + process = subprocess.run(argv, capture_output=True, text=True) + return process.stdout.strip() if process.returncode == 0 else None + + +def active_window(): + window = output("xdotool", "getactivewindow") + if not window: + return None + # Debian's xdotool predates getwindowclassname; WM_CLASS reads `= "inst", "Class"`. + wm_class = output("xprop", "-notype", "-id", window, "WM_CLASS") or "" + pid = output("xdotool", "getwindowpid", window) + return {"title": output("xdotool", "getwindowname", window) or "", + "class": wm_class.rsplit('"', 2)[-2] if wm_class.count('"') >= 2 else "", + "pid": int(pid) if pid else None} + + +def run_script(script, timeout_ms, shot_delay_ms): + with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: + # Files, not pipes: an app the script starts in the background inherits + # these descriptors and would otherwise hold the SSH channel open. + process = subprocess.Popen(["bash", "-c", script], stdin=subprocess.DEVNULL, + stdout=out, stderr=err, start_new_session=True) + try: + code = process.wait(timeout_ms / 1000) + error = None + time.sleep(shot_delay_ms / 1000) + shot = screen() + except subprocess.TimeoutExpired: + shot = screen() # before the kill, while whatever blocked is on screen + os.killpg(process.pid, signal.SIGKILL) + process.wait() + code = None + error = "script timed out after %d ms, process group killed" % timeout_ms + out.seek(0) + err.seek(0) + return dict(shot, exit=code, error=error, win=active_window(), + stdout=out.read().decode("utf-8", "replace"), + stderr=err.read().decode("utf-8", "replace")) + + +def ui_tree(): + import gi + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi, GLib + + win = active_window() + desktop = Atspi.get_desktop(0) + apps = [desktop.get_child_at_index(i) for i in range(desktop.get_child_count())] + chosen = [app for app in apps if win and app.get_process_id() == win["pid"]] or apps + lines = [] + + def walk(node, depth): + if len(lines) >= UI_LINE_LIMIT: + return + states = node.get_state_set() + if depth and not states.contains(Atspi.StateType.SHOWING): + return + line = "%s%s %r" % (" " * depth, node.get_role_name(), node.get_name()) + if depth: + try: + rect = node.get_extents(Atspi.CoordType.SCREEN) + line += " %d,%d,%d,%d" % (rect.x, rect.y, rect.width, rect.height) + except GLib.Error: + pass + text = node.get_text_iface() + if text: + value = Atspi.Text.get_text(text, 0, 200) + if value and value != node.get_name(): + line += " = %r" % value + if states.contains(Atspi.StateType.FOCUSED): + line += " [focused]" + lines.append(line) + for i in range(node.get_child_count()): + walk(node.get_child_at_index(i), depth + 1) + + for app in chosen: + walk(app, 0) + if len(lines) >= UI_LINE_LIMIT: + lines.append("... truncated at %d lines" % UI_LINE_LIMIT) + return {"win": win, "controls": "\n".join(lines)} + + +def main(request): + request = json.loads(request) + verb = request["verb"] + if verb == "exec": + reply = run_script(request["script"], request["timeout_ms"], + request["shot_delay_ms"]) + elif verb == "shot": + reply = dict(screen(), win=active_window()) + elif verb == "ui": + reply = ui_tree() + else: + raise ValueError("unknown verb %r" % verb) + print(json.dumps(reply)) diff --git a/tools/w7/linux_vm.py b/tools/w7/linux_vm.py index e7aedcd902e3ecef6eb6d3f894cc03a003eb3a64..7cbfcf5d88c91f35953b6a17f8274062f9d853c6 100755 --- a/tools/w7/linux_vm.py +++ b/tools/w7/linux_vm.py @@ -136,7 +136,42 @@ def overlay_path(name): return INSTANCES / (name + ".qcow2") -def make_seed(path, hostname, public_key, wan_mac, lab_mac): +DESKTOP_PACKAGES = [ + "xvfb", "openbox", "xdotool", "xclip", "maim", "x11-utils", "dbus-user-session", + "at-spi2-core", "python3-pyatspi", "libatk-adaptor", "mesa-vulkan-drivers", + "libgl1-mesa-dri", "libxkbcommon-x11-0", "libxcursor1", "libxi6", "libxrandr2", + "fonts-liberation", "fonts-crosextra-carlito", "build-essential", "pkg-config", + "rsync", "xterm", "zenity", +] +# 1280 wide keeps screenshots under the model's downscale threshold, so image +# pixels stay click coordinates. +DESKTOP_UNITS = { + "agent-display.service": """[Unit] +Description=Agent X display + +[Service] +ExecStart=/usr/bin/Xvfb :0 -screen 0 1280x800x24 -nolisten tcp + +[Install] +WantedBy=default.target +""", + "agent-wm.service": """[Unit] +Description=Agent window manager +Requires=agent-display.service +After=agent-display.service + +[Service] +ExecStartPre=/bin/sh -c 'until xdpyinfo >/dev/null 2>&1; do sleep 0.1; done' +ExecStartPre=/usr/bin/busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true +ExecStart=/usr/bin/openbox + +[Install] +WantedBy=default.target +""", +} + + +def make_seed(path, hostname, public_key, wan_mac, lab_mac, desktop=False): samba = """[global] workgroup = WORKGROUP server role = standalone server @@ -175,7 +210,7 @@ users: ssh_pwauth: false disable_root: true package_update: true -packages: [samba, dnsmasq, cifs-utils, smbclient, fio] +packages: [{packages}] write_files: - path: /etc/samba/smb.conf permissions: '0644' @@ -185,16 +220,35 @@ write_files: permissions: '0644' encoding: b64 content: {dnsmasq} -runcmd: +{desktop_files}runcmd: - [mkdir, -p, /srv/agent] - [chown, agent:agent, /srv/agent] - [systemctl, enable, --now, dnsmasq] - [systemctl, enable, --now, smbd] -""".format( +{desktop_commands}""".format( hostname=hostname, public_key=public_key, + packages=", ".join(["samba", "dnsmasq", "cifs-utils", "smbclient", "fio"] + + (DESKTOP_PACKAGES if desktop else [])), samba=base64.b64encode(samba.encode()).decode(), dnsmasq=base64.b64encode(dnsmasq.encode()).decode(), + # /etc/environment reaches SSH sessions; environment.d reaches user units. + desktop_files="".join( + " - path: %s\n append: true\n encoding: b64\n content: %s\n" + % (path, base64.b64encode(content.encode()).decode()) + for path, content in [("/etc/systemd/user/" + unit, text) + for unit, text in DESKTOP_UNITS.items()] + + [("/etc/environment", "DISPLAY=:0\n"), + ("/etc/environment.d/display.conf", "DISPLAY=:0\n")] + ) if desktop else "", + # The wait loop's SSH probes start the user manager before these units + # and dbus-user-session exist, so it must restart to pick them up. + desktop_commands=( + " - [systemctl, --global, enable, %s]\n" + " - [loginctl, enable-linger, agent]\n" + " - [sh, -c, 'systemctl restart user@$(id -u agent).service']\n" + % ", ".join(DESKTOP_UNITS) + ) if desktop else "", ) network = """version: 2 ethernets: @@ -268,7 +322,7 @@ def fetch_base(): def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, - ssh_port=None, samba_port=None): + ssh_port=None, samba_port=None, desktop=False): require_vm_home() validate_name(name) if not BASE.is_file() or not BASE_MANIFEST.is_file(): @@ -304,9 +358,10 @@ def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, "%dG" % disk_gb], check=True) wan = mac("wan:", name) lab = mac("lab:", name) - make_seed(seed, hostname, public.read_text().strip(), wan, lab) + make_seed(seed, hostname, public.read_text().strip(), wan, lab, desktop) seed.chmod(0o600) - config = {"cpus": cpus, "disk_gb": disk_gb, "hostname": hostname, + config = {"cpus": cpus, "desktop": desktop, "disk_gb": disk_gb, + "hostname": hostname, "lab_address": LAB_ADDRESS, "lab_mac": lab, "memory_mb": memory_mb, "samba_port": samba_port, "ssh_port": ssh_port, "wan_mac": wan} @@ -397,14 +452,14 @@ def wait_instance(name, timeout): else: raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name)) remaining = max(1, int(deadline - time.monotonic())) + validation = ("sudo cloud-init status --wait && " + "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && " + "systemctl is-active --quiet smbd dnsmasq") + if load_instance(name).get("desktop"): + validation += (" && timeout 60 sh -c 'until systemctl --user is-active --quiet " + "agent-wm; do sleep 0.2; done'") try: - result = run_ssh( - name, - "sudo cloud-init status --wait && " - "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && " - "systemctl is-active --quiet smbd dnsmasq", - timeout=remaining, - ) + result = run_ssh(name, validation, timeout=remaining) except subprocess.TimeoutExpired: raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name)) if result.returncode: @@ -491,6 +546,7 @@ def main(): up.add_argument("--disk", type=int, default=16, dest="disk_gb") up.add_argument("--ssh-port", type=int) up.add_argument("--samba-port", type=int) + up.add_argument("--desktop", action="store_true") up.add_argument("--wait", action="store_true") up.add_argument("--timeout", type=int, default=600) ssh = commands.add_parser("ssh") @@ -506,7 +562,7 @@ def main(): elif args.command == "up": if not instance_path(args.name).exists(): create_instance(args.name, args.cpus, args.memory_mb, args.disk_gb, - args.ssh_port, args.samba_port) + args.ssh_port, args.samba_port, args.desktop) if running(args.name): print("Linux VM already running: %s" % args.name) else: diff --git a/tools/w7/mcp_linux.py b/tools/w7/mcp_linux.py index 3154a9811f3a6a5f3770d68ff8a8038a489715ed..7789b87d2933621d7c82180f56955e08cc99d561 100755 --- a/tools/w7/mcp_linux.py +++ b/tools/w7/mcp_linux.py @@ -1,16 +1,46 @@ #!/usr/bin/env python3 -"""MCP server for disposable SSH-only Linux Samba VMs.""" +"""MCP server for disposable Linux VMs: Samba appliances and agent-driven desktops.""" import json from pathlib import Path +import shlex import subprocess import sys +import uuid + +import linux_vm +from mcp_win7 import shot_blocks, text_result ROOT = Path(__file__).resolve().parent VM = ROOT / "linux_vm.py" +GUEST = ROOT / "linux_desktop.py" +EXEC_DESCRIPTION = """Run a bash script on a desktop clone's X display (:0, 1280x800, +openbox). Returns exit code, stdout, stderr, the active window, and a screenshot +taken shot_delay_ms after the script exits. + +Screenshot pixels are screen coordinates. Put a whole sequence of actions in one +script; one call per click is slow and blind. Input is xdotool: + + xdotool mousemove 640 400 click 1 + xdotool type --delay 5 'literal text, {braces} and all' + xdotool key ctrl+a ctrl+c && xclip -o -selection clipboard + xdotool search --sync --name 'Title' windowactivate --sync + +Start GUI apps in the background (`app &`) so the script can go on driving them; +they outlive the script. Use linux_spawn for anything whose output you want to +read later. Close what you opened when the task is done. A timeout screenshots +the blocked screen, then kills the script's process group, including apps it +started.""" + +UI_DESCRIPTION = """Dump the active application's accessibility (AT-SPI) tree: +role, name, screen rect x,y,w,h, text value and focus for each showing node. +GTK 4 reports zero origins, so use its sizes and a screenshot for placement.""" + +NAME = {"type": "string", "description": "VM name."} + TOOLS = [ { "name": "linux_vm_fetch", @@ -24,8 +54,10 @@ TOOLS = [ "name": "linux_vm_up", "description": ( "Create a Linux clone when absent, then boot it headlessly. Creation settings " - "are ignored for an existing clone. Set wait to return after cloud-init and " - "Samba validation. One VM owns the shared lab address 192.168.77.1." + "are ignored for an existing clone. Set desktop for an X display driven by " + "linux_exec, linux_shot and linux_ui. Set wait to return after cloud-init, " + "Samba and (for desktops) display validation. One VM owns the shared lab " + "address 192.168.77.1." ), "inputSchema": { "type": "object", @@ -41,6 +73,7 @@ TOOLS = [ "maximum": 65535}, "samba_port": {"type": "integer", "minimum": 1024, "maximum": 65535}, + "desktop": {"type": "boolean", "default": False}, "wait": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 600, "minimum": 1, "maximum": 900}}, @@ -50,18 +83,81 @@ TOOLS = [ { "name": "linux_ssh", "description": ( - "Run a shell command over the clone's private SSH key. Use /srv/agent for " - "the Samba share exported to Windows as //192.168.77.1/agent." + "Run a shell command over the clone's private SSH key. No screenshot. Use " + "/srv/agent for the Samba share exported to Windows as //192.168.77.1/agent." ), "inputSchema": { "type": "object", - "properties": {"name": {"type": "string"}, + "properties": {"name": NAME, "command": {"type": "string"}, "timeout": {"type": "integer", "default": 120, "minimum": 1, "maximum": 900}}, "required": ["name", "command"], }, }, + { + "name": "linux_exec", + "description": EXEC_DESCRIPTION, + "inputSchema": { + "type": "object", + "properties": {"name": NAME, + "script": {"type": "string", "description": "bash source."}, + "shot_delay_ms": {"type": "integer", "default": 500}, + "timeout_ms": {"type": "integer", "default": 60000}}, + "required": ["name", "script"], + }, + }, + { + "name": "linux_shot", + "description": "Screenshot a desktop clone without running anything.", + "inputSchema": {"type": "object", "properties": {"name": NAME}, + "required": ["name"]}, + }, + { + "name": "linux_ui", + "description": UI_DESCRIPTION, + "inputSchema": {"type": "object", "properties": {"name": NAME}, + "required": ["name"]}, + }, + { + "name": "linux_spawn", + "description": ( + "Start a long-lived command as a transient systemd user unit on the display " + "and return its unit name immediately. Read its output with " + "`journalctl --user -u UNIT` and end it with `systemctl --user stop UNIT`." + ), + "inputSchema": { + "type": "object", + "properties": {"name": NAME, + "command": {"type": "string", "description": "bash source."}}, + "required": ["name", "command"], + }, + }, + { + "name": "linux_put", + "description": ( + "Copy a file from this Mac to a clone. The bytes never pass through the " + "conversation, so file size costs nothing." + ), + "inputSchema": { + "type": "object", + "properties": {"name": NAME, + "local": {"type": "string", "description": "Path on the Mac."}, + "remote": {"type": "string", "description": "Path on the clone."}}, + "required": ["name", "local", "remote"], + }, + }, + { + "name": "linux_get", + "description": "Copy a file from a clone back to this Mac.", + "inputSchema": { + "type": "object", + "properties": {"name": NAME, + "remote": {"type": "string", "description": "Path on the clone."}, + "local": {"type": "string", "description": "Path on the Mac."}}, + "required": ["name", "remote", "local"], + }, + }, { "name": "linux_vm_down", "description": ( @@ -70,7 +166,7 @@ TOOLS = [ ), "inputSchema": { "type": "object", - "properties": {"name": {"type": "string"}, + "properties": {"name": NAME, "timeout": {"type": "integer", "default": 60, "minimum": 1, "maximum": 120}, "preserve_machine": {"type": "boolean", "default": False}}, @@ -85,11 +181,30 @@ TOOLS = [ ] +def text(value): + return [{"type": "text", "text": value}] + + def run(argv, timeout=120): process = subprocess.run([sys.executable, str(VM)] + argv, capture_output=True, text=True, timeout=timeout) output = (process.stdout + process.stderr).strip() - return [{"type": "text", "text": output or "ok"}], process.returncode != 0 + return text(output or "ok"), process.returncode != 0 + + +def ssh(name, command, timeout, **streams): + try: + return subprocess.run(linux_vm.ssh_argv(name, command), timeout=timeout, **streams) + except subprocess.TimeoutExpired: + raise SystemExit("SSH to %s timed out after %d seconds" % (name, timeout)) + + +def guest(name, request, timeout=60): + source = GUEST.read_text() + "\nmain(%r)\n" % json.dumps(request) + process = ssh(name, "python3 -", timeout, input=source, capture_output=True, text=True) + if process.returncode: + raise SystemExit(process.stderr.strip() or "guest exited %d" % process.returncode) + return json.loads(process.stdout) def call_tool(name, args): @@ -102,6 +217,8 @@ def call_tool(name, args): ("samba_port", "--samba-port")): if args.get(key) is not None: argv += [option, str(args[key])] + if args.get("desktop"): + argv.append("--desktop") timeout = args.get("timeout", 600) if args.get("wait"): argv += ["--wait", "--timeout", str(timeout)] @@ -117,7 +234,45 @@ def call_tool(name, args): return run(argv, timeout + 15) if name == "linux_vm_status": return run(["status"] + ([args["name"]] if args.get("name") else [])) - return [{"type": "text", "text": "unknown tool: %s" % name}], True + vm = args["name"] + if name == "linux_exec": + timeout_ms = args.get("timeout_ms", 60000) + resp = guest(vm, {"verb": "exec", "script": args["script"], "timeout_ms": timeout_ms, + "shot_delay_ms": args.get("shot_delay_ms", 500)}, + timeout_ms // 1000 + 30) + return shot_blocks(resp, text_result(resp) + "\n"), False + if name == "linux_shot": + return shot_blocks(guest(vm, {"verb": "shot"})), False + if name == "linux_ui": + resp = guest(vm, {"verb": "ui"}) + win = resp.get("win") or {} + header = 'window: "%s" (%s)\n' % (win.get("title", ""), win.get("class", "")) + return text(header + resp["controls"]), False + if name == "linux_spawn": + unit = "spawn-" + uuid.uuid4().hex[:8] + process = ssh(vm, "systemd-run --user --collect --quiet --unit=%s -- bash -c %s" + % (unit, shlex.quote(args["command"])), 30, + capture_output=True, text=True) + return text(process.stderr.strip() or "unit=" + unit), process.returncode != 0 + if name == "linux_put": + remote = shlex.quote(args["remote"]) + with open(args["local"], "rb") as source: + process = ssh(vm, 'mkdir -p -- "$(dirname -- %s)" && cat > %s' % (remote, remote), + 600, stdin=source, capture_output=True) + if process.returncode: + return text(process.stderr.decode(errors="replace").strip()), True + return text("wrote %d bytes to %s" % (Path(args["local"]).stat().st_size, + args["remote"])), False + if name == "linux_get": + local = Path(args["local"]) + with local.open("wb") as target: + process = ssh(vm, "cat -- %s" % shlex.quote(args["remote"]), 600, + stdout=target, stderr=subprocess.PIPE) + if process.returncode: + local.unlink() + return text(process.stderr.decode(errors="replace").strip()), True + return text("read %d bytes to %s" % (local.stat().st_size, local.resolve())), False + return text("unknown tool: %s" % name), True def handle(method, params): @@ -129,8 +284,12 @@ def handle(method, params): if method == "tools/list": return {"tools": TOOLS} if method == "tools/call": - content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) - result = {"content": content} + try: + content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) + except SystemExit as e: # linux_vm reports every failure this way + content, is_error = text(str(e)), True + # Never structuredContent: Codex then drops content[] and its screenshot. + result = {"content": content, "_meta": {"codex/imageDetail": "original"}} if is_error: result["isError"] = True return result