| 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 | 163 | `one-linux` MCP exposes fetch, up, SSH, down, and status; its `up` and `down` |
| 164 | 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 | 185 | To restore a base from private object storage, upload the sealed qcow2 beside |
| 167 | 186 | its JSON manifest and pass the manifest URL. A presigned URL needs no secret; |
| 168 | 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 | 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 | 175 | samba = """[global] |
| 141 | 176 | workgroup = WORKGROUP |
| 142 | 177 | server role = standalone server |
| ... | ... | @@ -175,7 +210,7 @@ users: |
| 175 | 210 | ssh_pwauth: false |
| 176 | 211 | disable_root: true |
| 177 | 212 | package_update: true |
| 178 | packages: [samba, dnsmasq, cifs-utils, smbclient, fio] | |
| 213 | packages: [{packages}] | |
| 179 | 214 | write_files: |
| 180 | 215 | - path: /etc/samba/smb.conf |
| 181 | 216 | permissions: '0644' |
| ... | ... | @@ -185,16 +220,35 @@ write_files: |
| 185 | 220 | permissions: '0644' |
| 186 | 221 | encoding: b64 |
| 187 | 222 | content: {dnsmasq} |
| 188 | runcmd: | |
| 223 | {desktop_files}runcmd: | |
| 189 | 224 | - [mkdir, -p, /srv/agent] |
| 190 | 225 | - [chown, agent:agent, /srv/agent] |
| 191 | 226 | - [systemctl, enable, --now, dnsmasq] |
| 192 | 227 | - [systemctl, enable, --now, smbd] |
| 193 | """.format( | |
| 228 | {desktop_commands}""".format( | |
| 194 | 229 | hostname=hostname, |
| 195 | 230 | public_key=public_key, |
| 231 | packages=", ".join(["samba", "dnsmasq", "cifs-utils", "smbclient", "fio"] + | |
| 232 | (DESKTOP_PACKAGES if desktop else [])), | |
| 196 | 233 | samba=base64.b64encode(samba.encode()).decode(), |
| 197 | 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 | 253 | network = """version: 2 |
| 200 | 254 | ethernets: |
| ... | ... | @@ -268,7 +322,7 @@ def fetch_base(): |
| 268 | 322 | |
| 269 | 323 | |
| 270 | 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 | 326 | require_vm_home() |
| 273 | 327 | validate_name(name) |
| 274 | 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 | 358 | "%dG" % disk_gb], check=True) |
| 305 | 359 | wan = mac("wan:", name) |
| 306 | 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 | 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 | 365 | "lab_address": LAB_ADDRESS, "lab_mac": lab, |
| 311 | 366 | "memory_mb": memory_mb, "samba_port": samba_port, |
| 312 | 367 | "ssh_port": ssh_port, "wan_mac": wan} |
| ... | ... | @@ -397,14 +452,14 @@ def wait_instance(name, timeout): |
| 397 | 452 | else: |
| 398 | 453 | raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name)) |
| 399 | 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 | 461 | try: |
| 401 | result = run_ssh( | |
| 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 | ) | |
| 462 | result = run_ssh(name, validation, timeout=remaining) | |
| 408 | 463 | except subprocess.TimeoutExpired: |
| 409 | 464 | raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name)) |
| 410 | 465 | if result.returncode: |
| ... | ... | @@ -491,6 +546,7 @@ def main(): |
| 491 | 546 | up.add_argument("--disk", type=int, default=16, dest="disk_gb") |
| 492 | 547 | up.add_argument("--ssh-port", type=int) |
| 493 | 548 | up.add_argument("--samba-port", type=int) |
| 549 | up.add_argument("--desktop", action="store_true") | |
| 494 | 550 | up.add_argument("--wait", action="store_true") |
| 495 | 551 | up.add_argument("--timeout", type=int, default=600) |
| 496 | 552 | ssh = commands.add_parser("ssh") |
| ... | ... | @@ -506,7 +562,7 @@ def main(): |
| 506 | 562 | elif args.command == "up": |
| 507 | 563 | if not instance_path(args.name).exists(): |
| 508 | 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 | 566 | if running(args.name): |
| 511 | 567 | print("Linux VM already running: %s" % args.name) |
| 512 | 568 | else: |
tools/w7/mcp_linux.py+170-11| ... | ... | @@ -1,15 +1,45 @@ |
| 1 | 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 | 4 | import json |
| 5 | 5 | from pathlib import Path |
| 6 | import shlex | |
| 6 | 7 | import subprocess |
| 7 | 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 | 15 | ROOT = Path(__file__).resolve().parent |
| 11 | 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 | 44 | TOOLS = [ |
| 15 | 45 | { |
| ... | ... | @@ -24,8 +54,10 @@ TOOLS = [ |
| 24 | 54 | "name": "linux_vm_up", |
| 25 | 55 | "description": ( |
| 26 | 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 " | |
| 28 | "Samba validation. One VM owns the shared lab address 192.168.77.1." | |
| 57 | "are ignored for an existing clone. Set desktop for an X display driven by " | |
| 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 | 62 | "inputSchema": { |
| 31 | 63 | "type": "object", |
| ... | ... | @@ -41,6 +73,7 @@ TOOLS = [ |
| 41 | 73 | "maximum": 65535}, |
| 42 | 74 | "samba_port": {"type": "integer", "minimum": 1024, |
| 43 | 75 | "maximum": 65535}, |
| 76 | "desktop": {"type": "boolean", "default": False}, | |
| 44 | 77 | "wait": {"type": "boolean", "default": False}, |
| 45 | 78 | "timeout": {"type": "integer", "default": 600, |
| 46 | 79 | "minimum": 1, "maximum": 900}}, |
| ... | ... | @@ -50,18 +83,81 @@ TOOLS = [ |
| 50 | 83 | { |
| 51 | 84 | "name": "linux_ssh", |
| 52 | 85 | "description": ( |
| 53 | "Run a shell command over the clone's private SSH key. Use /srv/agent for " | |
| 54 | "the Samba share exported to Windows as //192.168.77.1/agent." | |
| 86 | "Run a shell command over the clone's private SSH key. No screenshot. Use " | |
| 87 | "/srv/agent for the Samba share exported to Windows as //192.168.77.1/agent." | |
| 55 | 88 | ), |
| 56 | 89 | "inputSchema": { |
| 57 | 90 | "type": "object", |
| 58 | "properties": {"name": {"type": "string"}, | |
| 91 | "properties": {"name": NAME, | |
| 59 | 92 | "command": {"type": "string"}, |
| 60 | 93 | "timeout": {"type": "integer", "default": 120, |
| 61 | 94 | "minimum": 1, "maximum": 900}}, |
| 62 | 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 | 162 | "name": "linux_vm_down", |
| 67 | 163 | "description": ( |
| ... | ... | @@ -70,7 +166,7 @@ TOOLS = [ |
| 70 | 166 | ), |
| 71 | 167 | "inputSchema": { |
| 72 | 168 | "type": "object", |
| 73 | "properties": {"name": {"type": "string"}, | |
| 169 | "properties": {"name": NAME, | |
| 74 | 170 | "timeout": {"type": "integer", "default": 60, |
| 75 | 171 | "minimum": 1, "maximum": 120}, |
| 76 | 172 | "preserve_machine": {"type": "boolean", "default": False}}, |
| ... | ... | @@ -85,11 +181,30 @@ TOOLS = [ |
| 85 | 181 | ] |
| 86 | 182 | |
| 87 | 183 | |
| 184 | def text(value): | |
| 185 | return [{"type": "text", "text": value}] | |
| 186 | ||
| 187 | ||
| 88 | 188 | def run(argv, timeout=120): |
| 89 | 189 | process = subprocess.run([sys.executable, str(VM)] + argv, capture_output=True, |
| 90 | 190 | text=True, timeout=timeout) |
| 91 | 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 | 210 | def call_tool(name, args): |
| ... | ... | @@ -102,6 +217,8 @@ def call_tool(name, args): |
| 102 | 217 | ("samba_port", "--samba-port")): |
| 103 | 218 | if args.get(key) is not None: |
| 104 | 219 | argv += [option, str(args[key])] |
| 220 | if args.get("desktop"): | |
| 221 | argv.append("--desktop") | |
| 105 | 222 | timeout = args.get("timeout", 600) |
| 106 | 223 | if args.get("wait"): |
| 107 | 224 | argv += ["--wait", "--timeout", str(timeout)] |
| ... | ... | @@ -117,7 +234,45 @@ def call_tool(name, args): |
| 117 | 234 | return run(argv, timeout + 15) |
| 118 | 235 | if name == "linux_vm_status": |
| 119 | 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 | 278 | def handle(method, params): |
| ... | ... | @@ -129,8 +284,12 @@ def handle(method, params): |
| 129 | 284 | if method == "tools/list": |
| 130 | 285 | return {"tools": TOOLS} |
| 131 | 286 | if method == "tools/call": |
| 132 | content, is_error = call_tool(params.get("name"), params.get("arguments") or {}) | |
| 133 | result = {"content": content} | |
| 287 | try: | |
| 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 | 293 | if is_error: |
| 135 | 294 | result["isError"] = True |
| 136 | 295 | return result |