| author | |
| committer | |
| log | cc25073ba74de4601e128acde1e294cc4bf1134a |
| tree | 5b86b7f4db5309ff0f1b15f5253375cc574065a1 |
| parent | 0aa624e18fc10573360eaa261231e2c5ca0e9d30 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
`up --desktop` adds Xvfb under openbox with xdotool, xclip, maim, AT-SPI and
lavapipe, and the one-linux MCP gains linux_exec, linux_shot, linux_ui,
linux_spawn, linux_put and linux_get, mirroring the Windows tools.
Assisted-by: claude-opus-5.54 files changed, 380 insertions(+), 26 deletions(-)
tools/w7/README.md+19| ... | @@ -163,6 +163,25 @@ clients. A local VDE switch joins up to 63 guests without host privileges. The | ... | @@ -163,6 +163,25 @@ clients. A local VDE switch joins up to 63 guests without host privileges. The |
| 163 | `one-linux` MCP exposes fetch, up, SSH, down, and status; its `up` and `down` | 163 | `one-linux` MCP exposes fetch, up, SSH, down, and status; its `up` and `down` |
| 164 | follow the same auto-create, wait-flag, and preserve conventions as Windows. | 164 | follow the same auto-create, wait-flag, and preserve conventions as Windows. |
| 165 | 165 | ||
| 166 | ## Linux desktop VM | ||
| 167 | |||
| 168 | `up --desktop` adds an X display for driving GUI apps: Xvfb `:0` at 1280x800 | ||
| 169 | under openbox, run as lingering systemd user units, with xdotool, xclip, maim, | ||
| 170 | AT-SPI and Mesa's software Vulkan (lavapipe) for wgpu. There is no guest agent; | ||
| 171 | each MCP call pipes [linux_desktop.py](linux_desktop.py) into `python3 -` over | ||
| 172 | SSH. | ||
| 173 | |||
| 174 | ```sh | ||
| 175 | ./linux_vm.py up desk --desktop --cpus 8 --memory 8192 --disk 40 --wait | ||
| 176 | ./linux_vm.py ssh desk -- 'xdotool getmouselocation' | ||
| 177 | ``` | ||
| 178 | |||
| 179 | `linux_exec` runs a bash script and returns output plus a screenshot, the | ||
| 180 | counterpart of `win7_exec` with xdotool in place of AutoHotkey. `linux_shot`, | ||
| 181 | `linux_ui` (AT-SPI tree), `linux_spawn` (transient user unit), `linux_put` and | ||
| 182 | `linux_get` mirror the Windows tools. A desktop clone is still a Samba | ||
| 183 | appliance, so it shares the one-at-a-time lab address rule. | ||
| 184 | |||
| 166 | To restore a base from private object storage, upload the sealed qcow2 beside | 185 | To restore a base from private object storage, upload the sealed qcow2 beside |
| 167 | its JSON manifest and pass the manifest URL. A presigned URL needs no secret; | 186 | its JSON manifest and pass the manifest URL. A presigned URL needs no secret; |
| 168 | otherwise set `ONE_VM_AUTHORIZATION` to the complete HTTP Authorization value. | 187 | otherwise set `ONE_VM_AUTHORIZATION` to the complete HTTP Authorization value. |
tools/w7/linux_desktop.py created+120| ... | @@ -0,0 +1,120 @@ | ||
| 1 | """Guest half of the one-linux desktop tools. | ||
| 2 | |||
| 3 | mcp_linux.py pipes this source plus a `main(request)` call into `python3 -` over | ||
| 4 | SSH, so the guest needs no installed agent and always runs the current code. | ||
| 5 | """ | ||
| 6 | |||
| 7 | import base64 | ||
| 8 | import json | ||
| 9 | import os | ||
| 10 | import signal | ||
| 11 | import struct | ||
| 12 | import subprocess | ||
| 13 | import tempfile | ||
| 14 | import time | ||
| 15 | |||
| 16 | UI_LINE_LIMIT = 1500 | ||
| 17 | |||
| 18 | |||
| 19 | def screen(): | ||
| 20 | png = subprocess.run(["maim", "--hidecursor"], capture_output=True, check=True).stdout | ||
| 21 | width, height = struct.unpack(">II", png[16:24]) | ||
| 22 | return {"png_b64": base64.b64encode(png).decode("ascii"), "w": width, "h": height} | ||
| 23 | |||
| 24 | |||
| 25 | def output(*argv): | ||
| 26 | process = subprocess.run(argv, capture_output=True, text=True) | ||
| 27 | return process.stdout.strip() if process.returncode == 0 else None | ||
| 28 | |||
| 29 | |||
| 30 | def active_window(): | ||
| 31 | window = output("xdotool", "getactivewindow") | ||
| 32 | if not window: | ||
| 33 | return None | ||
| 34 | # Debian's xdotool predates getwindowclassname; WM_CLASS reads `= "inst", "Class"`. | ||
| 35 | wm_class = output("xprop", "-notype", "-id", window, "WM_CLASS") or "" | ||
| 36 | pid = output("xdotool", "getwindowpid", window) | ||
| 37 | return {"title": output("xdotool", "getwindowname", window) or "", | ||
| 38 | "class": wm_class.rsplit('"', 2)[-2] if wm_class.count('"') >= 2 else "", | ||
| 39 | "pid": int(pid) if pid else None} | ||
| 40 | |||
| 41 | |||
| 42 | def run_script(script, timeout_ms, shot_delay_ms): | ||
| 43 | with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: | ||
| 44 | # Files, not pipes: an app the script starts in the background inherits | ||
| 45 | # these descriptors and would otherwise hold the SSH channel open. | ||
| 46 | process = subprocess.Popen(["bash", "-c", script], stdin=subprocess.DEVNULL, | ||
| 47 | stdout=out, stderr=err, start_new_session=True) | ||
| 48 | try: | ||
| 49 | code = process.wait(timeout_ms / 1000) | ||
| 50 | error = None | ||
| 51 | time.sleep(shot_delay_ms / 1000) | ||
| 52 | shot = screen() | ||
| 53 | except subprocess.TimeoutExpired: | ||
| 54 | shot = screen() # before the kill, while whatever blocked is on screen | ||
| 55 | os.killpg(process.pid, signal.SIGKILL) | ||
| 56 | process.wait() | ||
| 57 | code = None | ||
| 58 | error = "script timed out after %d ms, process group killed" % timeout_ms | ||
| 59 | out.seek(0) | ||
| 60 | err.seek(0) | ||
| 61 | return dict(shot, exit=code, error=error, win=active_window(), | ||
| 62 | stdout=out.read().decode("utf-8", "replace"), | ||
| 63 | stderr=err.read().decode("utf-8", "replace")) | ||
| 64 | |||
| 65 | |||
| 66 | def ui_tree(): | ||
| 67 | import gi | ||
| 68 | gi.require_version("Atspi", "2.0") | ||
| 69 | from gi.repository import Atspi, GLib | ||
| 70 | |||
| 71 | win = active_window() | ||
| 72 | desktop = Atspi.get_desktop(0) | ||
| 73 | apps = [desktop.get_child_at_index(i) for i in range(desktop.get_child_count())] | ||
| 74 | chosen = [app for app in apps if win and app.get_process_id() == win["pid"]] or apps | ||
| 75 | lines = [] | ||
| 76 | |||
| 77 | def walk(node, depth): | ||
| 78 | if len(lines) >= UI_LINE_LIMIT: | ||
| 79 | return | ||
| 80 | states = node.get_state_set() | ||
| 81 | if depth and not states.contains(Atspi.StateType.SHOWING): | ||
| 82 | return | ||
| 83 | line = "%s%s %r" % (" " * depth, node.get_role_name(), node.get_name()) | ||
| 84 | if depth: | ||
| 85 | try: | ||
| 86 | rect = node.get_extents(Atspi.CoordType.SCREEN) | ||
| 87 | line += " %d,%d,%d,%d" % (rect.x, rect.y, rect.width, rect.height) | ||
| 88 | except GLib.Error: | ||
| 89 | pass | ||
| 90 | text = node.get_text_iface() | ||
| 91 | if text: | ||
| 92 | value = Atspi.Text.get_text(text, 0, 200) | ||
| 93 | if value and value != node.get_name(): | ||
| 94 | line += " = %r" % value | ||
| 95 | if states.contains(Atspi.StateType.FOCUSED): | ||
| 96 | line += " [focused]" | ||
| 97 | lines.append(line) | ||
| 98 | for i in range(node.get_child_count()): | ||
| 99 | walk(node.get_child_at_index(i), depth + 1) | ||
| 100 | |||
| 101 | for app in chosen: | ||
| 102 | walk(app, 0) | ||
| 103 | if len(lines) >= UI_LINE_LIMIT: | ||
| 104 | lines.append("... truncated at %d lines" % UI_LINE_LIMIT) | ||
| 105 | return {"win": win, "controls": "\n".join(lines)} | ||
| 106 | |||
| 107 | |||
| 108 | def main(request): | ||
| 109 | request = json.loads(request) | ||
| 110 | verb = request["verb"] | ||
| 111 | if verb == "exec": | ||
| 112 | reply = run_script(request["script"], request["timeout_ms"], | ||
| 113 | request["shot_delay_ms"]) | ||
| 114 | elif verb == "shot": | ||
| 115 | reply = dict(screen(), win=active_window()) | ||
| 116 | elif verb == "ui": | ||
| 117 | reply = ui_tree() | ||
| 118 | else: | ||
| 119 | raise ValueError("unknown verb %r" % verb) | ||
| 120 | print(json.dumps(reply)) | ||
tools/w7/linux_vm.py+71-15| ... | @@ -136,7 +136,42 @@ def overlay_path(name): | ... | @@ -136,7 +136,42 @@ def overlay_path(name): |
| 136 | return INSTANCES / (name + ".qcow2") | 136 | return INSTANCES / (name + ".qcow2") |
| 137 | 137 | ||
| 138 | 138 | ||
| 139 | def make_seed(path, hostname, public_key, wan_mac, lab_mac): | 139 | DESKTOP_PACKAGES = [ |
| 140 | "xvfb", "openbox", "xdotool", "xclip", "maim", "x11-utils", "dbus-user-session", | ||
| 141 | "at-spi2-core", "python3-pyatspi", "libatk-adaptor", "mesa-vulkan-drivers", | ||
| 142 | "libgl1-mesa-dri", "libxkbcommon-x11-0", "libxcursor1", "libxi6", "libxrandr2", | ||
| 143 | "fonts-liberation", "fonts-crosextra-carlito", "build-essential", "pkg-config", | ||
| 144 | "rsync", "xterm", "zenity", | ||
| 145 | ] | ||
| 146 | # 1280 wide keeps screenshots under the model's downscale threshold, so image | ||
| 147 | # pixels stay click coordinates. | ||
| 148 | DESKTOP_UNITS = { | ||
| 149 | "agent-display.service": """[Unit] | ||
| 150 | Description=Agent X display | ||
| 151 | |||
| 152 | [Service] | ||
| 153 | ExecStart=/usr/bin/Xvfb :0 -screen 0 1280x800x24 -nolisten tcp | ||
| 154 | |||
| 155 | [Install] | ||
| 156 | WantedBy=default.target | ||
| 157 | """, | ||
| 158 | "agent-wm.service": """[Unit] | ||
| 159 | Description=Agent window manager | ||
| 160 | Requires=agent-display.service | ||
| 161 | After=agent-display.service | ||
| 162 | |||
| 163 | [Service] | ||
| 164 | ExecStartPre=/bin/sh -c 'until xdpyinfo >/dev/null 2>&1; do sleep 0.1; done' | ||
| 165 | ExecStartPre=/usr/bin/busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true | ||
| 166 | ExecStart=/usr/bin/openbox | ||
| 167 | |||
| 168 | [Install] | ||
| 169 | WantedBy=default.target | ||
| 170 | """, | ||
| 171 | } | ||
| 172 | |||
| 173 | |||
| 174 | def make_seed(path, hostname, public_key, wan_mac, lab_mac, desktop=False): | ||
| 140 | samba = """[global] | 175 | samba = """[global] |
| 141 | workgroup = WORKGROUP | 176 | workgroup = WORKGROUP |
| 142 | server role = standalone server | 177 | server role = standalone server |
| ... | @@ -175,7 +210,7 @@ users: | ... | @@ -175,7 +210,7 @@ users: |
| 175 | ssh_pwauth: false | 210 | ssh_pwauth: false |
| 176 | disable_root: true | 211 | disable_root: true |
| 177 | package_update: true | 212 | package_update: true |
| 178 | packages: [samba, dnsmasq, cifs-utils, smbclient, fio] | 213 | packages: [{packages}] |
| 179 | write_files: | 214 | write_files: |
| 180 | - path: /etc/samba/smb.conf | 215 | - path: /etc/samba/smb.conf |
| 181 | permissions: '0644' | 216 | permissions: '0644' |
| ... | @@ -185,16 +220,35 @@ write_files: | ... | @@ -185,16 +220,35 @@ write_files: |
| 185 | permissions: '0644' | 220 | permissions: '0644' |
| 186 | encoding: b64 | 221 | encoding: b64 |
| 187 | content: {dnsmasq} | 222 | content: {dnsmasq} |
| 188 | runcmd: | 223 | {desktop_files}runcmd: |
| 189 | - [mkdir, -p, /srv/agent] | 224 | - [mkdir, -p, /srv/agent] |
| 190 | - [chown, agent:agent, /srv/agent] | 225 | - [chown, agent:agent, /srv/agent] |
| 191 | - [systemctl, enable, --now, dnsmasq] | 226 | - [systemctl, enable, --now, dnsmasq] |
| 192 | - [systemctl, enable, --now, smbd] | 227 | - [systemctl, enable, --now, smbd] |
| 193 | """.format( | 228 | {desktop_commands}""".format( |
| 194 | hostname=hostname, | 229 | hostname=hostname, |
| 195 | public_key=public_key, | 230 | public_key=public_key, |
| 231 | packages=", ".join(["samba", "dnsmasq", "cifs-utils", "smbclient", "fio"] + | ||
| 232 | (DESKTOP_PACKAGES if desktop else [])), | ||
| 196 | samba=base64.b64encode(samba.encode()).decode(), | 233 | samba=base64.b64encode(samba.encode()).decode(), |
| 197 | dnsmasq=base64.b64encode(dnsmasq.encode()).decode(), | 234 | dnsmasq=base64.b64encode(dnsmasq.encode()).decode(), |
| 235 | # /etc/environment reaches SSH sessions; environment.d reaches user units. | ||
| 236 | desktop_files="".join( | ||
| 237 | " - path: %s\n append: true\n encoding: b64\n content: %s\n" | ||
| 238 | % (path, base64.b64encode(content.encode()).decode()) | ||
| 239 | for path, content in [("/etc/systemd/user/" + unit, text) | ||
| 240 | for unit, text in DESKTOP_UNITS.items()] + | ||
| 241 | [("/etc/environment", "DISPLAY=:0\n"), | ||
| 242 | ("/etc/environment.d/display.conf", "DISPLAY=:0\n")] | ||
| 243 | ) if desktop else "", | ||
| 244 | # The wait loop's SSH probes start the user manager before these units | ||
| 245 | # and dbus-user-session exist, so it must restart to pick them up. | ||
| 246 | desktop_commands=( | ||
| 247 | " - [systemctl, --global, enable, %s]\n" | ||
| 248 | " - [loginctl, enable-linger, agent]\n" | ||
| 249 | " - [sh, -c, 'systemctl restart user@$(id -u agent).service']\n" | ||
| 250 | % ", ".join(DESKTOP_UNITS) | ||
| 251 | ) if desktop else "", | ||
| 198 | ) | 252 | ) |
| 199 | network = """version: 2 | 253 | network = """version: 2 |
| 200 | ethernets: | 254 | ethernets: |
| ... | @@ -268,7 +322,7 @@ def fetch_base(): | ... | @@ -268,7 +322,7 @@ def fetch_base(): |
| 268 | 322 | ||
| 269 | 323 | ||
| 270 | def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, | 324 | def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, |
| 271 | ssh_port=None, samba_port=None): | 325 | ssh_port=None, samba_port=None, desktop=False): |
| 272 | require_vm_home() | 326 | require_vm_home() |
| 273 | validate_name(name) | 327 | validate_name(name) |
| 274 | if not BASE.is_file() or not BASE_MANIFEST.is_file(): | 328 | 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, | ... | @@ -304,9 +358,10 @@ def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, |
| 304 | "%dG" % disk_gb], check=True) | 358 | "%dG" % disk_gb], check=True) |
| 305 | wan = mac("wan:", name) | 359 | wan = mac("wan:", name) |
| 306 | lab = mac("lab:", name) | 360 | lab = mac("lab:", name) |
| 307 | make_seed(seed, hostname, public.read_text().strip(), wan, lab) | 361 | make_seed(seed, hostname, public.read_text().strip(), wan, lab, desktop) |
| 308 | seed.chmod(0o600) | 362 | seed.chmod(0o600) |
| 309 | config = {"cpus": cpus, "disk_gb": disk_gb, "hostname": hostname, | 363 | config = {"cpus": cpus, "desktop": desktop, "disk_gb": disk_gb, |
| 364 | "hostname": hostname, | ||
| 310 | "lab_address": LAB_ADDRESS, "lab_mac": lab, | 365 | "lab_address": LAB_ADDRESS, "lab_mac": lab, |
| 311 | "memory_mb": memory_mb, "samba_port": samba_port, | 366 | "memory_mb": memory_mb, "samba_port": samba_port, |
| 312 | "ssh_port": ssh_port, "wan_mac": wan} | 367 | "ssh_port": ssh_port, "wan_mac": wan} |
| ... | @@ -397,14 +452,14 @@ def wait_instance(name, timeout): | ... | @@ -397,14 +452,14 @@ def wait_instance(name, timeout): |
| 397 | else: | 452 | else: |
| 398 | raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name)) | 453 | raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name)) |
| 399 | remaining = max(1, int(deadline - time.monotonic())) | 454 | remaining = max(1, int(deadline - time.monotonic())) |
| 455 | validation = ("sudo cloud-init status --wait && " | ||
| 456 | "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && " | ||
| 457 | "systemctl is-active --quiet smbd dnsmasq") | ||
| 458 | if load_instance(name).get("desktop"): | ||
| 459 | validation += (" && timeout 60 sh -c 'until systemctl --user is-active --quiet " | ||
| 460 | "agent-wm; do sleep 0.2; done'") | ||
| 400 | try: | 461 | try: |
| 401 | result = run_ssh( | 462 | result = run_ssh(name, validation, timeout=remaining) |
| 402 | name, | ||
| 403 | "sudo cloud-init status --wait && " | ||
| 404 | "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && " | ||
| 405 | "systemctl is-active --quiet smbd dnsmasq", | ||
| 406 | timeout=remaining, | ||
| 407 | ) | ||
| 408 | except subprocess.TimeoutExpired: | 463 | except subprocess.TimeoutExpired: |
| 409 | raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name)) | 464 | raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name)) |
| 410 | if result.returncode: | 465 | if result.returncode: |
| ... | @@ -491,6 +546,7 @@ def main(): | ... | @@ -491,6 +546,7 @@ def main(): |
| 491 | up.add_argument("--disk", type=int, default=16, dest="disk_gb") | 546 | up.add_argument("--disk", type=int, default=16, dest="disk_gb") |
| 492 | up.add_argument("--ssh-port", type=int) | 547 | up.add_argument("--ssh-port", type=int) |
| 493 | up.add_argument("--samba-port", type=int) | 548 | up.add_argument("--samba-port", type=int) |
| 549 | up.add_argument("--desktop", action="store_true") | ||
| 494 | up.add_argument("--wait", action="store_true") | 550 | up.add_argument("--wait", action="store_true") |
| 495 | up.add_argument("--timeout", type=int, default=600) | 551 | up.add_argument("--timeout", type=int, default=600) |
| 496 | ssh = commands.add_parser("ssh") | 552 | ssh = commands.add_parser("ssh") |
| ... | @@ -506,7 +562,7 @@ def main(): | ... | @@ -506,7 +562,7 @@ def main(): |
| 506 | elif args.command == "up": | 562 | elif args.command == "up": |
| 507 | if not instance_path(args.name).exists(): | 563 | if not instance_path(args.name).exists(): |
| 508 | create_instance(args.name, args.cpus, args.memory_mb, args.disk_gb, | 564 | create_instance(args.name, args.cpus, args.memory_mb, args.disk_gb, |
| 509 | args.ssh_port, args.samba_port) | 565 | args.ssh_port, args.samba_port, args.desktop) |
| 510 | if running(args.name): | 566 | if running(args.name): |
| 511 | print("Linux VM already running: %s" % args.name) | 567 | print("Linux VM already running: %s" % args.name) |
| 512 | else: | 568 | else: |
tools/w7/mcp_linux.py+170-11| ... | @@ -1,15 +1,45 @@ | ... | @@ -1,15 +1,45 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """MCP server for disposable SSH-only Linux Samba VMs.""" | 2 | """MCP server for disposable Linux VMs: Samba appliances and agent-driven desktops.""" |
| 3 | 3 | ||
| 4 | import json | 4 | import json |
| 5 | from pathlib import Path | 5 | from pathlib import Path |
| 6 | import shlex | ||
| 6 | import subprocess | 7 | import subprocess |
| 7 | import sys | 8 | import sys |
| 9 | import uuid | ||
| 10 | |||
| 11 | import linux_vm | ||
| 12 | from mcp_win7 import shot_blocks, text_result | ||
| 8 | 13 | ||
| 9 | 14 | ||
| 10 | ROOT = Path(__file__).resolve().parent | 15 | ROOT = Path(__file__).resolve().parent |
| 11 | VM = ROOT / "linux_vm.py" | 16 | VM = ROOT / "linux_vm.py" |
| 17 | GUEST = ROOT / "linux_desktop.py" | ||
| 18 | |||
| 19 | |||
| 20 | EXEC_DESCRIPTION = """Run a bash script on a desktop clone's X display (:0, 1280x800, | ||
| 21 | openbox). Returns exit code, stdout, stderr, the active window, and a screenshot | ||
| 22 | taken shot_delay_ms after the script exits. | ||
| 23 | |||
| 24 | Screenshot pixels are screen coordinates. Put a whole sequence of actions in one | ||
| 25 | script; one call per click is slow and blind. Input is xdotool: | ||
| 26 | |||
| 27 | xdotool mousemove 640 400 click 1 | ||
| 28 | xdotool type --delay 5 'literal text, {braces} and all' | ||
| 29 | xdotool key ctrl+a ctrl+c && xclip -o -selection clipboard | ||
| 30 | xdotool search --sync --name 'Title' windowactivate --sync | ||
| 31 | |||
| 32 | Start GUI apps in the background (`app &`) so the script can go on driving them; | ||
| 33 | they outlive the script. Use linux_spawn for anything whose output you want to | ||
| 34 | read later. Close what you opened when the task is done. A timeout screenshots | ||
| 35 | the blocked screen, then kills the script's process group, including apps it | ||
| 36 | started.""" | ||
| 12 | 37 | ||
| 38 | UI_DESCRIPTION = """Dump the active application's accessibility (AT-SPI) tree: | ||
| 39 | role, name, screen rect x,y,w,h, text value and focus for each showing node. | ||
| 40 | GTK 4 reports zero origins, so use its sizes and a screenshot for placement.""" | ||
| 41 | |||
| 42 | NAME = {"type": "string", "description": "VM name."} | ||
| 13 | 43 | ||
| 14 | TOOLS = [ | 44 | TOOLS = [ |
| 15 | { | 45 | { |
| ... | @@ -24,8 +54,10 @@ TOOLS = [ | ... | @@ -24,8 +54,10 @@ TOOLS = [ |
| 24 | "name": "linux_vm_up", | 54 | "name": "linux_vm_up", |
| 25 | "description": ( | 55 | "description": ( |
| 26 | "Create a Linux clone when absent, then boot it headlessly. Creation settings " | 56 | "Create a Linux clone when absent, then boot it headlessly. Creation settings " |
| 27 | "are ignored for an existing clone. Set wait to return after cloud-init and " | 57 | "are ignored for an existing clone. Set desktop for an X display driven by " |
| 28 | "Samba validation. One VM owns the shared lab address 192.168.77.1." | 58 | "linux_exec, linux_shot and linux_ui. Set wait to return after cloud-init, " |
| 59 | "Samba and (for desktops) display validation. One VM owns the shared lab " | ||
| 60 | "address 192.168.77.1." | ||
| 29 | ), | 61 | ), |
| 30 | "inputSchema": { | 62 | "inputSchema": { |
| 31 | "type": "object", | 63 | "type": "object", |
| ... | @@ -41,6 +73,7 @@ TOOLS = [ | ... | @@ -41,6 +73,7 @@ TOOLS = [ |
| 41 | "maximum": 65535}, | 73 | "maximum": 65535}, |
| 42 | "samba_port": {"type": "integer", "minimum": 1024, | 74 | "samba_port": {"type": "integer", "minimum": 1024, |
| 43 | "maximum": 65535}, | 75 | "maximum": 65535}, |
| 76 | "desktop": {"type": "boolean", "default": False}, | ||
| 44 | "wait": {"type": "boolean", "default": False}, | 77 | "wait": {"type": "boolean", "default": False}, |
| 45 | "timeout": {"type": "integer", "default": 600, | 78 | "timeout": {"type": "integer", "default": 600, |
| 46 | "minimum": 1, "maximum": 900}}, | 79 | "minimum": 1, "maximum": 900}}, |
| ... | @@ -50,18 +83,81 @@ TOOLS = [ | ... | @@ -50,18 +83,81 @@ TOOLS = [ |
| 50 | { | 83 | { |
| 51 | "name": "linux_ssh", | 84 | "name": "linux_ssh", |
| 52 | "description": ( | 85 | "description": ( |
| 53 | "Run a shell command over the clone's private SSH key. Use /srv/agent for " | 86 | "Run a shell command over the clone's private SSH key. No screenshot. Use " |
| 54 | "the Samba share exported to Windows as //192.168.77.1/agent." | 87 | "/srv/agent for the Samba share exported to Windows as //192.168.77.1/agent." |
| 55 | ), | 88 | ), |
| 56 | "inputSchema": { | 89 | "inputSchema": { |
| 57 | "type": "object", | 90 | "type": "object", |
| 58 | "properties": {"name": {"type": "string"}, | 91 | "properties": {"name": NAME, |
| 59 | "command": {"type": "string"}, | 92 | "command": {"type": "string"}, |
| 60 | "timeout": {"type": "integer", "default": 120, | 93 | "timeout": {"type": "integer", "default": 120, |
| 61 | "minimum": 1, "maximum": 900}}, | 94 | "minimum": 1, "maximum": 900}}, |
| 62 | "required": ["name", "command"], | 95 | "required": ["name", "command"], |
| 63 | }, | 96 | }, |
| 64 | }, | 97 | }, |
| 98 | { | ||
| 99 | "name": "linux_exec", | ||
| 100 | "description": EXEC_DESCRIPTION, | ||
| 101 | "inputSchema": { | ||
| 102 | "type": "object", | ||
| 103 | "properties": {"name": NAME, | ||
| 104 | "script": {"type": "string", "description": "bash source."}, | ||
| 105 | "shot_delay_ms": {"type": "integer", "default": 500}, | ||
| 106 | "timeout_ms": {"type": "integer", "default": 60000}}, | ||
| 107 | "required": ["name", "script"], | ||
| 108 | }, | ||
| 109 | }, | ||
| 110 | { | ||
| 111 | "name": "linux_shot", | ||
| 112 | "description": "Screenshot a desktop clone without running anything.", | ||
| 113 | "inputSchema": {"type": "object", "properties": {"name": NAME}, | ||
| 114 | "required": ["name"]}, | ||
| 115 | }, | ||
| 116 | { | ||
| 117 | "name": "linux_ui", | ||
| 118 | "description": UI_DESCRIPTION, | ||
| 119 | "inputSchema": {"type": "object", "properties": {"name": NAME}, | ||
| 120 | "required": ["name"]}, | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | "name": "linux_spawn", | ||
| 124 | "description": ( | ||
| 125 | "Start a long-lived command as a transient systemd user unit on the display " | ||
| 126 | "and return its unit name immediately. Read its output with " | ||
| 127 | "`journalctl --user -u UNIT` and end it with `systemctl --user stop UNIT`." | ||
| 128 | ), | ||
| 129 | "inputSchema": { | ||
| 130 | "type": "object", | ||
| 131 | "properties": {"name": NAME, | ||
| 132 | "command": {"type": "string", "description": "bash source."}}, | ||
| 133 | "required": ["name", "command"], | ||
| 134 | }, | ||
| 135 | }, | ||
| 136 | { | ||
| 137 | "name": "linux_put", | ||
| 138 | "description": ( | ||
| 139 | "Copy a file from this Mac to a clone. The bytes never pass through the " | ||
| 140 | "conversation, so file size costs nothing." | ||
| 141 | ), | ||
| 142 | "inputSchema": { | ||
| 143 | "type": "object", | ||
| 144 | "properties": {"name": NAME, | ||
| 145 | "local": {"type": "string", "description": "Path on the Mac."}, | ||
| 146 | "remote": {"type": "string", "description": "Path on the clone."}}, | ||
| 147 | "required": ["name", "local", "remote"], | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | { | ||
| 151 | "name": "linux_get", | ||
| 152 | "description": "Copy a file from a clone back to this Mac.", | ||
| 153 | "inputSchema": { | ||
| 154 | "type": "object", | ||
| 155 | "properties": {"name": NAME, | ||
| 156 | "remote": {"type": "string", "description": "Path on the clone."}, | ||
| 157 | "local": {"type": "string", "description": "Path on the Mac."}}, | ||
| 158 | "required": ["name", "remote", "local"], | ||
| 159 | }, | ||
| 160 | }, | ||
| 65 | { | 161 | { |
| 66 | "name": "linux_vm_down", | 162 | "name": "linux_vm_down", |
| 67 | "description": ( | 163 | "description": ( |
| ... | @@ -70,7 +166,7 @@ TOOLS = [ | ... | @@ -70,7 +166,7 @@ TOOLS = [ |
| 70 | ), | 166 | ), |
| 71 | "inputSchema": { | 167 | "inputSchema": { |
| 72 | "type": "object", | 168 | "type": "object", |
| 73 | "properties": {"name": {"type": "string"}, | 169 | "properties": {"name": NAME, |
| 74 | "timeout": {"type": "integer", "default": 60, | 170 | "timeout": {"type": "integer", "default": 60, |
| 75 | "minimum": 1, "maximum": 120}, | 171 | "minimum": 1, "maximum": 120}, |
| 76 | "preserve_machine": {"type": "boolean", "default": False}}, | 172 | "preserve_machine": {"type": "boolean", "default": False}}, |
| ... | @@ -85,11 +181,30 @@ TOOLS = [ | ... | @@ -85,11 +181,30 @@ TOOLS = [ |
| 85 | ] | 181 | ] |
| 86 | 182 | ||
| 87 | 183 | ||
| 184 | def text(value): | ||
| 185 | return [{"type": "text", "text": value}] | ||
| 186 | |||
| 187 | |||
| 88 | def run(argv, timeout=120): | 188 | def run(argv, timeout=120): |
| 89 | process = subprocess.run([sys.executable, str(VM)] + argv, capture_output=True, | 189 | process = subprocess.run([sys.executable, str(VM)] + argv, capture_output=True, |
| 90 | text=True, timeout=timeout) | 190 | text=True, timeout=timeout) |
| 91 | output = (process.stdout + process.stderr).strip() | 191 | output = (process.stdout + process.stderr).strip() |
| 92 | return [{"type": "text", "text": output or "ok"}], process.returncode != 0 | 192 | return text(output or "ok"), process.returncode != 0 |
| 193 | |||
| 194 | |||
| 195 | def ssh(name, command, timeout, **streams): | ||
| 196 | try: | ||
| 197 | return subprocess.run(linux_vm.ssh_argv(name, command), timeout=timeout, **streams) | ||
| 198 | except subprocess.TimeoutExpired: | ||
| 199 | raise SystemExit("SSH to %s timed out after %d seconds" % (name, timeout)) | ||
| 200 | |||
| 201 | |||
| 202 | def guest(name, request, timeout=60): | ||
| 203 | source = GUEST.read_text() + "\nmain(%r)\n" % json.dumps(request) | ||
| 204 | process = ssh(name, "python3 -", timeout, input=source, capture_output=True, text=True) | ||
| 205 | if process.returncode: | ||
| 206 | raise SystemExit(process.stderr.strip() or "guest exited %d" % process.returncode) | ||
| 207 | return json.loads(process.stdout) | ||
| 93 | 208 | ||
| 94 | 209 | ||
| 95 | def call_tool(name, args): | 210 | def call_tool(name, args): |
| ... | @@ -102,6 +217,8 @@ def call_tool(name, args): | ... | @@ -102,6 +217,8 @@ def call_tool(name, args): |
| 102 | ("samba_port", "--samba-port")): | 217 | ("samba_port", "--samba-port")): |
| 103 | if args.get(key) is not None: | 218 | if args.get(key) is not None: |
| 104 | argv += [option, str(args[key])] | 219 | argv += [option, str(args[key])] |
| 220 | if args.get("desktop"): | ||
| 221 | argv.append("--desktop") | ||
| 105 | timeout = args.get("timeout", 600) | 222 | timeout = args.get("timeout", 600) |
| 106 | if args.get("wait"): | 223 | if args.get("wait"): |
| 107 | argv += ["--wait", "--timeout", str(timeout)] | 224 | argv += ["--wait", "--timeout", str(timeout)] |
| ... | @@ -117,7 +234,45 @@ def call_tool(name, args): | ... | @@ -117,7 +234,45 @@ def call_tool(name, args): |
| 117 | return run(argv, timeout + 15) | 234 | return run(argv, timeout + 15) |
| 118 | if name == "linux_vm_status": | 235 | if name == "linux_vm_status": |
| 119 | return run(["status"] + ([args["name"]] if args.get("name") else [])) | 236 | return run(["status"] + ([args["name"]] if args.get("name") else [])) |
| 120 | return [{"type": "text", "text": "unknown tool: %s" % name}], True | 237 | vm = args["name"] |
| 238 | if name == "linux_exec": | ||
| 239 | timeout_ms = args.get("timeout_ms", 60000) | ||
| 240 | resp = guest(vm, {"verb": "exec", "script": args["script"], "timeout_ms": timeout_ms, | ||
| 241 | "shot_delay_ms": args.get("shot_delay_ms", 500)}, | ||
| 242 | timeout_ms // 1000 + 30) | ||
| 243 | return shot_blocks(resp, text_result(resp) + "\n"), False | ||
| 244 | if name == "linux_shot": | ||
| 245 | return shot_blocks(guest(vm, {"verb": "shot"})), False | ||
| 246 | if name == "linux_ui": | ||
| 247 | resp = guest(vm, {"verb": "ui"}) | ||
| 248 | win = resp.get("win") or {} | ||
| 249 | header = 'window: "%s" (%s)\n' % (win.get("title", ""), win.get("class", "")) | ||
| 250 | return text(header + resp["controls"]), False | ||
| 251 | if name == "linux_spawn": | ||
| 252 | unit = "spawn-" + uuid.uuid4().hex[:8] | ||
| 253 | process = ssh(vm, "systemd-run --user --collect --quiet --unit=%s -- bash -c %s" | ||
| 254 | % (unit, shlex.quote(args["command"])), 30, | ||
| 255 | capture_output=True, text=True) | ||
| 256 | return text(process.stderr.strip() or "unit=" + unit), process.returncode != 0 | ||
| 257 | if name == "linux_put": | ||
| 258 | remote = shlex.quote(args["remote"]) | ||
| 259 | with open(args["local"], "rb") as source: | ||
| 260 | process = ssh(vm, 'mkdir -p -- "$(dirname -- %s)" && cat > %s' % (remote, remote), | ||
| 261 | 600, stdin=source, capture_output=True) | ||
| 262 | if process.returncode: | ||
| 263 | return text(process.stderr.decode(errors="replace").strip()), True | ||
| 264 | return text("wrote %d bytes to %s" % (Path(args["local"]).stat().st_size, | ||
| 265 | args["remote"])), False | ||
| 266 | if name == "linux_get": | ||
| 267 | local = Path(args["local"]) | ||
| 268 | with local.open("wb") as target: | ||
| 269 | process = ssh(vm, "cat -- %s" % shlex.quote(args["remote"]), 600, | ||
| 270 | stdout=target, stderr=subprocess.PIPE) | ||
| 271 | if process.returncode: | ||
| 272 | local.unlink() | ||
| 273 | return text(process.stderr.decode(errors="replace").strip()), True | ||
| 274 | return text("read %d bytes to %s" % (local.stat().st_size, local.resolve())), False | ||
| 275 | return text("unknown tool: %s" % name), True | ||
| 121 | 276 | ||
| 122 | 277 | ||
| 123 | def handle(method, params): | 278 | def handle(method, params): |
| ... | @@ -129,8 +284,12 @@ def handle(method, params): | ... | @@ -129,8 +284,12 @@ def handle(method, params): |
| 129 | if method == "tools/list": | 284 | if method == "tools/list": |
| 130 | return {"tools": TOOLS} | 285 | return {"tools": TOOLS} |
| 131 | if method == "tools/call": | 286 | if method == "tools/call": |
| 132 | content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) | 287 | try: |
| 133 | result = {"content": content} | 288 | content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) |
| 289 | except SystemExit as e: # linux_vm reports every failure this way | ||
| 290 | content, is_error = text(str(e)), True | ||
| 291 | # Never structuredContent: Codex then drops content[] and its screenshot. | ||
| 292 | result = {"content": content, "_meta": {"codex/imageDetail": "original"}} | ||
| 134 | if is_error: | 293 | if is_error: |
| 135 | result["isError"] = True | 294 | result["isError"] = True |
| 136 | return result | 295 | return result |