authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:35:21-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 17:39:00-07:00
logcc25073ba74de4601e128acde1e294cc4bf1134a
tree5b86b7f4db5309ff0f1b15f5253375cc574065a1
parent0aa624e18fc10573360eaa261231e2c5ca0e9d30
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the Linux lab VM can run a desktop, driven over SSH like the Windows lab

`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.5

4 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`
164follow the same auto-create, wait-flag, and preserve conventions as Windows.164follow the same auto-create, wait-flag, and preserve conventions as Windows.
165165
166## Linux desktop VM
167
168`up --desktop` adds an X display for driving GUI apps: Xvfb `:0` at 1280x800
169under openbox, run as lingering systemd user units, with xdotool, xclip, maim,
170AT-SPI and Mesa's software Vulkan (lavapipe) for wgpu. There is no guest agent;
171each MCP call pipes [linux_desktop.py](linux_desktop.py) into `python3 -` over
172SSH.
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
180counterpart 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
183appliance, so it shares the one-at-a-time lab address rule.
184
166To restore a base from private object storage, upload the sealed qcow2 beside185To restore a base from private object storage, upload the sealed qcow2 beside
167its JSON manifest and pass the manifest URL. A presigned URL needs no secret;186its JSON manifest and pass the manifest URL. A presigned URL needs no secret;
168otherwise set `ONE_VM_AUTHORIZATION` to the complete HTTP Authorization value.187otherwise 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
3mcp_linux.py pipes this source plus a `main(request)` call into `python3 -` over
4SSH, so the guest needs no installed agent and always runs the current code.
5"""
6
7import base64
8import json
9import os
10import signal
11import struct
12import subprocess
13import tempfile
14import time
15
16UI_LINE_LIMIT = 1500
17
18
19def 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
25def 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
30def 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
42def 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
66def 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
108def 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")
137137
138138
139def make_seed(path, hostname, public_key, wan_mac, lab_mac):139DESKTOP_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.
148DESKTOP_UNITS = {
149 "agent-display.service": """[Unit]
150Description=Agent X display
151
152[Service]
153ExecStart=/usr/bin/Xvfb :0 -screen 0 1280x800x24 -nolisten tcp
154
155[Install]
156WantedBy=default.target
157""",
158 "agent-wm.service": """[Unit]
159Description=Agent window manager
160Requires=agent-display.service
161After=agent-display.service
162
163[Service]
164ExecStartPre=/bin/sh -c 'until xdpyinfo >/dev/null 2>&1; do sleep 0.1; done'
165ExecStartPre=/usr/bin/busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true
166ExecStart=/usr/bin/openbox
167
168[Install]
169WantedBy=default.target
170""",
171}
172
173
174def make_seed(path, hostname, public_key, wan_mac, lab_mac, desktop=False):
140 samba = """[global]175 samba = """[global]
141workgroup = WORKGROUP176workgroup = WORKGROUP
142server role = standalone server177server role = standalone server
...@@ -175,7 +210,7 @@ users:...@@ -175,7 +210,7 @@ users:
175ssh_pwauth: false210ssh_pwauth: false
176disable_root: true211disable_root: true
177package_update: true212package_update: true
178packages: [samba, dnsmasq, cifs-utils, smbclient, fio]213packages: [{packages}]
179write_files:214write_files:
180 - path: /etc/samba/smb.conf215 - 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: b64221 encoding: b64
187 content: {dnsmasq}222 content: {dnsmasq}
188runcmd: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: 2253 network = """version: 2
200ethernets:254ethernets:
...@@ -268,7 +322,7 @@ def fetch_base():...@@ -268,7 +322,7 @@ def fetch_base():
268322
269323
270def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16,324def 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 python31#!/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."""
33
4import json4import json
5from pathlib import Path5from pathlib import Path
6import shlex
6import subprocess7import subprocess
7import sys8import sys
9import uuid
10
11import linux_vm
12from mcp_win7 import shot_blocks, text_result
813
914
10ROOT = Path(__file__).resolve().parent15ROOT = Path(__file__).resolve().parent
11VM = ROOT / "linux_vm.py"16VM = ROOT / "linux_vm.py"
17GUEST = ROOT / "linux_desktop.py"
18
19
20EXEC_DESCRIPTION = """Run a bash script on a desktop clone's X display (:0, 1280x800,
21openbox). Returns exit code, stdout, stderr, the active window, and a screenshot
22taken shot_delay_ms after the script exits.
23
24Screenshot pixels are screen coordinates. Put a whole sequence of actions in one
25script; 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
32Start GUI apps in the background (`app &`) so the script can go on driving them;
33they outlive the script. Use linux_spawn for anything whose output you want to
34read later. Close what you opened when the task is done. A timeout screenshots
35the blocked screen, then kills the script's process group, including apps it
36started."""
1237
38UI_DESCRIPTION = """Dump the active application's accessibility (AT-SPI) tree:
39role, name, screen rect x,y,w,h, text value and focus for each showing node.
40GTK 4 reports zero origins, so use its sizes and a screenshot for placement."""
41
42NAME = {"type": "string", "description": "VM name."}
1343
14TOOLS = [44TOOLS = [
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]
86182
87183
184def text(value):
185 return [{"type": "text", "text": value}]
186
187
88def run(argv, timeout=120):188def 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 != 0192 return text(output or "ok"), process.returncode != 0
193
194
195def 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
202def 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)
93208
94209
95def call_tool(name, args):210def 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}], True237 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
121276
122277
123def handle(method, params):278def 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"] = True294 result["isError"] = True
136 return result295 return result