quality-loop 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {quality_loop-0.3.0/quality_loop.egg-info → quality_loop-0.4.0}/PKG-INFO +3 -1
  2. {quality_loop-0.3.0 → quality_loop-0.4.0}/README.md +2 -0
  3. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/__init__.py +1 -1
  4. quality_loop-0.4.0/quality_loop/guest/__init__.py +580 -0
  5. quality_loop-0.4.0/quality_loop/guest/boot/__init__.py +7 -0
  6. quality_loop-0.4.0/quality_loop/guest/boot/launch.py +35 -0
  7. quality_loop-0.4.0/quality_loop/guest/boot/screen_agent.py +54 -0
  8. quality_loop-0.4.0/quality_loop/guest/screens.py +138 -0
  9. {quality_loop-0.3.0 → quality_loop-0.4.0/quality_loop.egg-info}/PKG-INFO +3 -1
  10. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/SOURCES.txt +6 -0
  11. quality_loop-0.4.0/tests/test_guest.py +215 -0
  12. {quality_loop-0.3.0 → quality_loop-0.4.0}/LICENSE +0 -0
  13. {quality_loop-0.3.0 → quality_loop-0.4.0}/pyproject.toml +0 -0
  14. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/OPERATOR.md +0 -0
  15. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/__main__.py +0 -0
  16. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/adapter.py +0 -0
  17. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/audit.py +0 -0
  18. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/cli.py +0 -0
  19. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/compare.py +0 -0
  20. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/config.py +0 -0
  21. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/__init__.py +0 -0
  22. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/_mac.py +0 -0
  23. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/_windows.py +0 -0
  24. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/keys.py +0 -0
  25. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/terminal.py +0 -0
  26. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/install.py +0 -0
  27. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/mcp.py +0 -0
  28. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/operator.py +0 -0
  29. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/pages.py +0 -0
  30. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/processes.py +0 -0
  31. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/progress.py +0 -0
  32. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/report.py +0 -0
  33. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/room.py +0 -0
  34. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/runner.py +0 -0
  35. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/scenario.py +0 -0
  36. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/__init__.py +0 -0
  37. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/_mac.py +0 -0
  38. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/_windows.py +0 -0
  39. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/session.py +0 -0
  40. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/SKILL.md +0 -0
  41. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/references/filing.md +0 -0
  42. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/references/setup.md +0 -0
  43. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/standin.py +0 -0
  44. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/transcript.py +0 -0
  45. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/dependency_links.txt +0 -0
  46. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/entry_points.txt +0 -0
  47. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/requires.txt +0 -0
  48. {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/top_level.txt +0 -0
  49. {quality_loop-0.3.0 → quality_loop-0.4.0}/setup.cfg +0 -0
  50. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_core.py +0 -0
  51. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_drive.py +0 -0
  52. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_loop.py +0 -0
  53. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_mcp.py +0 -0
  54. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_operator.py +0 -0
  55. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_scenario_report.py +0 -0
  56. {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_screens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quality-loop
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Measure what a product teaches - its skill, tools or manual - with isolated agent runs of real tasks, reported by an operator agent
5
5
  Author: craftware
6
6
  License-Expression: MIT
@@ -110,6 +110,8 @@ under a policy the person sets. See [Running the loop](https://github.com/crftwr
110
110
  launch command and a redirected home up to evidence tools of its own.
111
111
  - [Reports](https://github.com/crftwr/quality-loop/blob/main/docs/reports.md): problems, severities, kinds and ids, the
112
112
  reproduction record, and `FINDINGS.md` / `findings.json`.
113
+ - [Running the app in a VM](https://github.com/crftwr/quality-loop/blob/main/docs/guests.md): one image per OS,
114
+ the guest over SSH, a clean machine every run, screens in the guest.
113
115
  - [Isolation and permissions](https://github.com/crftwr/quality-loop/blob/main/docs/isolation.md): what keeps the room, the
114
116
  operator and the conductor in bounds, why each rule exists, and the known limits.
115
117
  - [Development](https://github.com/crftwr/quality-loop/blob/main/docs/development.md): tests, live tests, releasing.
@@ -79,6 +79,8 @@ under a policy the person sets. See [Running the loop](https://github.com/crftwr
79
79
  launch command and a redirected home up to evidence tools of its own.
80
80
  - [Reports](https://github.com/crftwr/quality-loop/blob/main/docs/reports.md): problems, severities, kinds and ids, the
81
81
  reproduction record, and `FINDINGS.md` / `findings.json`.
82
+ - [Running the app in a VM](https://github.com/crftwr/quality-loop/blob/main/docs/guests.md): one image per OS,
83
+ the guest over SSH, a clean machine every run, screens in the guest.
82
84
  - [Isolation and permissions](https://github.com/crftwr/quality-loop/blob/main/docs/isolation.md): what keeps the room, the
83
85
  operator and the conductor in bounds, why each rule exists, and the known limits.
84
86
  - [Development](https://github.com/crftwr/quality-loop/blob/main/docs/development.md): tests, live tests, releasing.
@@ -12,4 +12,4 @@ room is handed, putting the user's machine back - is code here, not an
12
12
  agent's discretion.
13
13
  """
14
14
 
15
- __version__ = "0.3.0"
15
+ __version__ = "0.4.0"
@@ -0,0 +1,580 @@
1
+ """A product's app in a VM, off the host's keyboard and screen (keyhac#271, xefm#750).
2
+
3
+ Everything Claude does stays on the host: the room, the operator, the
4
+ evidence, the rooms directory. Only the product's app moves into a guest, and
5
+ the run reaches it the way it reaches a local one:
6
+
7
+ - **A folder is shared.** The host writes into its host side (the stage, the
8
+ run's home, results); the guest reads the same files under its guest side.
9
+ :meth:`Guest.path` turns one into the other.
10
+ - **The guest is an SSH host alias.** Its address, user and key live in
11
+ ``~/.ssh/config``, never here. A port the app opens in the guest is
12
+ forwarded to the host (:meth:`Guest.forward`), and one the host serves is
13
+ forwarded into the guest (:meth:`Guest.reverse`).
14
+ - **The guest's runtime is its own image's.** The image carries a Python
15
+ runtime and nothing that is under test (xefm#750, "Decisions on the shared
16
+ test image"): the product's working tree and what else its makers build are
17
+ staged every run, and third-party packages are installed into a fresh venv
18
+ in the guest every run (:meth:`Guest.venv`), with pip's download cache on
19
+ the host side of the share so a revert does not lose the downloads.
20
+ - **What has to be on the desktop runs in the desktop session**, where a
21
+ window is seen and can take focus - not in the SSH session, which has
22
+ neither. Windows starts it from a scheduled task run as the logged-in user
23
+ (:meth:`Guest.run_in_session`).
24
+
25
+ The VM should be clean when a run starts: a Tart clone of a base image, or a
26
+ VMware snapshot reverted to. When the per-machine settings name the VM for
27
+ the alias, :meth:`Guest.machine` does that itself.
28
+
29
+ Per-machine settings live outside every repository - they name this
30
+ machine's VMs, paths and Keychain items, never a secret itself -
31
+ in ``~/.config/quality-loop/guests.toml`` (``QUALITY_LOOP_GUESTS`` moves it):
32
+
33
+ [qa-mac]
34
+ share = "~/.cache/qa-guest=/Volumes/My Shared Files/qa"
35
+ python = "/Library/Frameworks/Python.framework/Versions/3.14/bin/python3"
36
+ tart = "<base VM>" # cloned per run, deleted after
37
+ visible = true
38
+
39
+ [qa-win]
40
+ share = "~/mnt/qa-win=C:\\\\qa"
41
+ python = "C:\\\\Python314\\\\python.exe"
42
+ vmx = "~/Virtual Machines.localized/<VM>.vmwarevm/<VM>.vmx"
43
+ snapshot = "<snapshot>" # reverted to before the run
44
+ vm_password = "keychain:<service>" # the VM's encryption password
45
+ smb = "//<user>@<guest>/<share>" # remounted on the share's host side
46
+ smb_password = "keychain:<service>"
47
+
48
+ Without a VM named, the run uses the guest as it is, already running.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import contextlib
54
+ import hashlib
55
+ import json
56
+ import ntpath
57
+ import os
58
+ import pathlib
59
+ import posixpath
60
+ import shutil
61
+ import socket
62
+ import subprocess
63
+ import tempfile
64
+ import time
65
+ import tomllib
66
+ import urllib.parse
67
+
68
+ #: Per-machine settings, by SSH alias. QUALITY_LOOP_GUESTS moves it.
69
+ GUESTS = pathlib.Path("~/.config/quality-loop/guests.toml")
70
+
71
+ HERE = pathlib.Path(__file__).resolve().parent
72
+
73
+
74
+ class GuestError(Exception):
75
+ """The guest could not be reached, started or used."""
76
+
77
+
78
+ def settings_for(alias: str, path: pathlib.Path | str | None = None) -> dict:
79
+ """The table for ``alias`` in the per-machine settings, or ``{}``."""
80
+ where = pathlib.Path(path or os.environ.get("QUALITY_LOOP_GUESTS", GUESTS)).expanduser()
81
+ try:
82
+ with where.open("rb") as f:
83
+ return tomllib.load(f).get(alias, {})
84
+ except FileNotFoundError:
85
+ return {}
86
+ except tomllib.TOMLDecodeError as error:
87
+ raise GuestError(f"{where}: {error}") from None
88
+
89
+
90
+ def secret(value: str) -> str:
91
+ """``keychain:<service>`` read from the login Keychain; anything else as
92
+ it is. Read when a run needs it, never written anywhere."""
93
+ if not value.startswith("keychain:"):
94
+ return value
95
+ service = value.removeprefix("keychain:")
96
+ said = subprocess.run(["security", "find-generic-password", "-s", service, "-w"],
97
+ capture_output=True, text=True)
98
+ if said.returncode != 0:
99
+ raise GuestError(f"no Keychain item {service!r}: add it with "
100
+ f"security add-generic-password -a <account> -s {service} -w")
101
+ return said.stdout.rstrip("\n")
102
+
103
+
104
+ def sh_quote(word: str) -> str:
105
+ return "'" + word.replace("'", "'\\''") + "'"
106
+
107
+
108
+ def ps_quote(word: str) -> str:
109
+ """A PowerShell single-quoted literal."""
110
+ return "'" + word.replace("'", "''") + "'"
111
+
112
+
113
+ class Guest:
114
+ """One VM, reached as an SSH host alias, sharing a folder with the host."""
115
+
116
+ def __init__(self, alias: str, host_share: pathlib.Path, guest_share: str,
117
+ settings: dict | None = None):
118
+ self.alias = alias
119
+ self.host_share = pathlib.Path(host_share).resolve()
120
+ self.guest_share = guest_share
121
+ self.settings = settings or {}
122
+ self._windows: bool | None = None
123
+ #: Set while a run's clone is up: its address, which the alias's
124
+ #: HostName does not know.
125
+ self.hostname: str | None = None
126
+
127
+ @classmethod
128
+ def from_settings(cls, alias: str, settings: dict, share: str | None = None) -> "Guest":
129
+ """A guest from its settings table; ``share`` (``host=guest``)
130
+ overrides the table's."""
131
+ share = share or settings.get("share", "")
132
+ host, _, guest = share.partition("=")
133
+ if not host or not guest:
134
+ raise GuestError(f"the guest {alias!r} needs share = <host dir>=<guest dir>: "
135
+ "a folder shared with the VM, as each side sees it")
136
+ host_share = pathlib.Path(host).expanduser()
137
+ if not host_share.is_dir():
138
+ raise GuestError(f"the share's host side, {host_share}, is not a directory")
139
+ return cls(alias, host_share, guest, settings)
140
+
141
+ @classmethod
142
+ def from_environment(cls, variable: str = "QUALITY_LOOP_GUEST",
143
+ share_variable: str | None = None,
144
+ settings_path: pathlib.Path | str | None = None) -> "Guest | None":
145
+ """The guest ``variable`` names, or None when it names none. A product
146
+ passes its own variable names (``KEYHAC_QA_GUEST``)."""
147
+ alias = os.environ.get(variable)
148
+ if not alias:
149
+ return None
150
+ share = os.environ.get(share_variable) if share_variable else None
151
+ return cls.from_settings(alias, settings_for(alias, settings_path), share)
152
+
153
+ def to_settings(self) -> dict:
154
+ """What :mod:`quality_loop.guest.screens` needs to reach this guest
155
+ from another process: JSON, no secrets."""
156
+ return {"alias": self.alias, "hostname": self.hostname,
157
+ "host_share": str(self.host_share), "guest_share": self.guest_share,
158
+ "windows": self.windows, "python": self.python}
159
+
160
+ @classmethod
161
+ def from_screen_settings(cls, settings: dict) -> "Guest":
162
+ guest = cls(settings["alias"], pathlib.Path(settings["host_share"]),
163
+ settings["guest_share"], {"python": settings.get("python")})
164
+ guest._windows = settings["windows"]
165
+ guest.hostname = settings.get("hostname")
166
+ return guest
167
+
168
+ # --- talking to it ---------------------------------------------------------
169
+
170
+ def ssh_options(self) -> list[str]:
171
+ if self.hostname is None:
172
+ return []
173
+ # A clone answers with its base's host key at a new address each time.
174
+ return ["-o", f"HostName={self.hostname}", "-o", f"HostKeyAlias={self.alias}",
175
+ "-o", "StrictHostKeyChecking=accept-new"]
176
+
177
+ def ssh(self, command: str, timeout: float = 60) -> subprocess.CompletedProcess:
178
+ return subprocess.run(["ssh", "-o", "BatchMode=yes", *self.ssh_options(),
179
+ self.alias, command],
180
+ capture_output=True, text=True, timeout=timeout,
181
+ stdin=subprocess.DEVNULL)
182
+
183
+ def reachable(self, patience: float) -> bool:
184
+ """Whether SSH gets in within ``patience`` seconds."""
185
+ deadline = time.monotonic() + patience
186
+ while time.monotonic() < deadline:
187
+ try:
188
+ if self.ssh("echo up", timeout=15).stdout.strip() == "up":
189
+ return True
190
+ except subprocess.TimeoutExpired:
191
+ pass
192
+ time.sleep(3)
193
+ return False
194
+
195
+ def machine(self):
196
+ """A context manager for the run: the VM up from a clean state, and
197
+ put away afterwards, when the settings name one."""
198
+ if "tart" in self.settings:
199
+ return TartMachine(self)
200
+ if "vmx" in self.settings:
201
+ return VMwareMachine(self)
202
+ return contextlib.nullcontext()
203
+
204
+ @property
205
+ def windows(self) -> bool:
206
+ """Whether the guest is Windows - asked once. A Windows guest's shell
207
+ is PowerShell, where `uname` is no command."""
208
+ if self._windows is None:
209
+ said = self.ssh("uname -s")
210
+ if said.returncode == 255:
211
+ raise GuestError(f"cannot reach the VM as {self.alias!r}:\n{said.stderr}")
212
+ self._windows = said.stdout.strip() != "Darwin"
213
+ return self._windows
214
+
215
+ @property
216
+ def platform(self) -> str:
217
+ """``sys.platform`` as the guest's own Python would say it."""
218
+ return "win32" if self.windows else "darwin"
219
+
220
+ @property
221
+ def python(self) -> str:
222
+ """The image's interpreter, as the guest names it (``python`` in the
223
+ settings)."""
224
+ python = self.settings.get("python")
225
+ if not python:
226
+ raise GuestError(f"the guest {self.alias!r} has no python = in its settings: "
227
+ "the image's interpreter, as the guest names it")
228
+ return python
229
+
230
+ def path(self, host_path: pathlib.Path | str) -> str:
231
+ """A path under the host side of the share, as the guest sees it."""
232
+ parts = pathlib.Path(host_path).resolve().relative_to(self.host_share).parts
233
+ join = ntpath.join if self.windows else posixpath.join
234
+ return join(self.guest_share, *parts)
235
+
236
+ def mkdtemp(self, prefix: str) -> pathlib.Path:
237
+ """A new directory on the share (its host side)."""
238
+ return pathlib.Path(tempfile.mkdtemp(prefix=prefix, dir=self.host_share)).resolve()
239
+
240
+ def kill(self, pid: int) -> None:
241
+ if self.windows:
242
+ self.ssh(f"Stop-Process -Id {int(pid)} -Force -ErrorAction SilentlyContinue")
243
+ else:
244
+ self.ssh(f"kill -INT {int(pid)}; sleep 3; kill -9 {int(pid)} 2>/dev/null; true")
245
+
246
+ def forward(self, port: int) -> subprocess.Popen:
247
+ """The guest's 127.0.0.1:port as the host's, until terminated."""
248
+ return self._tunnel("-L", port)
249
+
250
+ def reverse(self, port: int) -> subprocess.Popen:
251
+ """The host's 127.0.0.1:port as the guest's, until terminated."""
252
+ return self._tunnel("-R", port, start_new_session=True)
253
+
254
+ def _tunnel(self, direction: str, port: int, **popen) -> subprocess.Popen:
255
+ return subprocess.Popen(
256
+ ["ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
257
+ *self.ssh_options(), direction, f"{port}:127.0.0.1:{port}", self.alias],
258
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
259
+ **popen)
260
+
261
+ # --- what runs in it ---------------------------------------------------------
262
+
263
+ def stage(self, prefix: str = "ql-stage-") -> pathlib.Path:
264
+ """A new stage on the share, carrying what runs in the guest on
265
+ quality-loop's behalf: its screen backends and the boot scripts
266
+ (``<stage>/ql``). The product adds its own files beside them."""
267
+ stage = self.mkdtemp(prefix)
268
+ ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
269
+ package = HERE.parent
270
+ ql = stage / "ql" / "quality_loop"
271
+ ql.mkdir(parents=True)
272
+ # quality_loop/__init__.py and screens/ need nothing else; guest/ is
273
+ # copied for its boot scripts.
274
+ shutil.copy2(package / "__init__.py", ql / "__init__.py")
275
+ shutil.copytree(package / "screens", ql / "screens", ignore=ignore)
276
+ shutil.copytree(HERE, ql / "guest", ignore=ignore)
277
+ return stage
278
+
279
+ def boot_script(self, stage: pathlib.Path, name: str) -> pathlib.Path:
280
+ """``name`` among the boot scripts, as staged."""
281
+ return stage / "ql" / "quality_loop" / "guest" / "boot" / name
282
+
283
+ def venv(self, stage: pathlib.Path, requirements: list[str], *,
284
+ timeout: float = 900) -> str:
285
+ """A fresh venv in the guest with ``requirements`` installed, from the
286
+ image's Python; returns its interpreter as the guest names it.
287
+
288
+ Made on the guest's own disk, which the revert after the run throws
289
+ away, so every run gets what the working tree asks for now. pip's
290
+ download cache is on the share (``<host share>/.pip-cache``), so the
291
+ next run downloads only what changed. Nothing that is under test goes
292
+ in here: the product stages its own code and its makers' packages."""
293
+ lines = "\n".join(requirements) + "\n"
294
+ digest = hashlib.sha256(lines.encode()).hexdigest()[:12]
295
+ (stage / "requirements.txt").write_text(lines, encoding="utf-8")
296
+ cache = self.host_share / ".pip-cache"
297
+ cache.mkdir(exist_ok=True)
298
+ if self.windows:
299
+ where = rf"$env:LOCALAPPDATA\ql-venv-{digest}"
300
+ python = rf"{where}\Scripts\python.exe"
301
+ command = (f"$ErrorActionPreference = 'Stop'; "
302
+ f"& {ps_quote(self.python)} -m venv \"{where}\"; "
303
+ f"if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}; "
304
+ f"& \"{python}\" -m pip install --disable-pip-version-check -q "
305
+ f"--cache-dir {ps_quote(self.path(cache))} "
306
+ f"-r {ps_quote(self.path(stage / 'requirements.txt'))}; "
307
+ f"if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}; "
308
+ f"Write-Output \"{python}\"")
309
+ else:
310
+ where = f"$HOME/.ql-venv-{digest}"
311
+ python = f"{where}/bin/python"
312
+ command = (f"set -e; {sh_quote(self.python)} -m venv \"{where}\"; "
313
+ f"\"{python}\" -m pip install --disable-pip-version-check -q "
314
+ f"--cache-dir {sh_quote(self.path(cache))} "
315
+ f"-r {sh_quote(self.path(stage / 'requirements.txt'))}; "
316
+ f"echo \"{python}\"")
317
+ said = self.ssh(command, timeout=timeout)
318
+ if said.returncode != 0:
319
+ raise GuestError(f"could not install the run's packages in the VM:\n"
320
+ f"{said.stdout[-1500:]}{said.stderr[-1500:]}")
321
+ return said.stdout.strip().splitlines()[-1].strip()
322
+
323
+ def run_in_session(self, stage: pathlib.Path, task: str, python: str,
324
+ script: pathlib.Path, *arguments: str) -> None:
325
+ """Windows: start ``script`` (on the stage) with ``python`` - a
326
+ ``pythonw.exe``, which opens no console window on that desktop - in
327
+ the logged-in user's desktop session, and return.
328
+
329
+ A scheduled task, run interactively, is how a process gets into that
330
+ session from SSH, where a window it opens is seen and EnumWindows
331
+ finds it. The task is XML, not schtasks /tr: the program and its
332
+ arguments stay apart, so nothing has to survive PowerShell's quoting
333
+ of a native command line - and the ScheduledTasks cmdlets need CIM,
334
+ which refuses a standard user over SSH. The task is left defined;
335
+ :meth:`forget_task` removes it."""
336
+ if not self.windows:
337
+ raise GuestError("run_in_session is for a Windows guest; on macOS, start "
338
+ "the app with `open`, which reaches the desktop session")
339
+ definition = stage / f"{task}.xml"
340
+ definition.write_text(task_xml(
341
+ python, " ".join(f'"{a}"' for a in ("-s", self.path(script), *arguments))),
342
+ encoding="utf-16")
343
+ quoted = ps_quote(task)
344
+ said = self.ssh(f"schtasks /create /tn {quoted} /xml "
345
+ f"{ps_quote(self.path(definition))} /f; schtasks /run /tn {quoted}")
346
+ if "SUCCESS: Attempted to run" not in said.stdout:
347
+ raise GuestError(f"could not run {script.name} in the VM's desktop session:\n"
348
+ f"{said.stdout}{said.stderr}")
349
+
350
+ def launch(self, stage: pathlib.Path, task: str, python: str, *,
351
+ script: str, log: pathlib.Path, environ: dict | None = None,
352
+ path: list[str] | None = None, cwd: str | None = None,
353
+ argv: list[str] | None = None) -> None:
354
+ """Windows: run ``script`` (a guest path) as ``__main__`` in the
355
+ desktop session, through ``boot/launch.py``: ``environ`` set, ``path``
356
+ put in front of ``sys.path``, ``cwd`` entered, stdout and stderr into
357
+ ``log`` (on the share), ``argv`` as its arguments. ``python`` should be
358
+ a ``pythonw.exe``: a console window would open on the desktop the room
359
+ looks at."""
360
+ settings = {"log": self.path(log), "script": script,
361
+ "environ": dict(environ or {}), "path": list(path or []),
362
+ "cwd": cwd, "argv": list(argv or [])}
363
+ launch = stage / f"{task}.json"
364
+ launch.write_text(json.dumps(settings), encoding="utf-8")
365
+ self.run_in_session(stage, task, python, self.boot_script(stage, "launch.py"),
366
+ self.path(launch))
367
+
368
+ def forget_task(self, task: str) -> None:
369
+ if self.windows:
370
+ self.ssh(f"schtasks /delete /tn {ps_quote(task)} /f")
371
+
372
+ def windowless(self, python: str) -> str:
373
+ """``python``'s ``pythonw.exe`` beside it, on Windows."""
374
+ if self.windows and python.lower().endswith("python.exe"):
375
+ return python[:-len("python.exe")] + "pythonw.exe"
376
+ return python
377
+
378
+
379
+ def task_xml(command: str, arguments: str) -> str:
380
+ """A scheduled task that runs ``command`` in the creating user's desktop
381
+ session (InteractiveToken), with no time limit."""
382
+ from xml.sax.saxutils import escape
383
+ return f"""<?xml version="1.0" encoding="UTF-16"?>
384
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
385
+ <Principals>
386
+ <Principal id="Author">
387
+ <LogonType>InteractiveToken</LogonType>
388
+ <RunLevel>LeastPrivilege</RunLevel>
389
+ </Principal>
390
+ </Principals>
391
+ <Settings>
392
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
393
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
394
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
395
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
396
+ <Enabled>true</Enabled>
397
+ </Settings>
398
+ <Actions Context="Author">
399
+ <Exec>
400
+ <Command>{escape(command)}</Command>
401
+ <Arguments>{escape(arguments)}</Arguments>
402
+ </Exec>
403
+ </Actions>
404
+ </Task>
405
+ """
406
+
407
+
408
+ def listening(port: int) -> bool:
409
+ """Whether something answers on the host's 127.0.0.1:port."""
410
+ with socket.socket() as probe:
411
+ probe.settimeout(1.0)
412
+ return probe.connect_ex(("127.0.0.1", port)) == 0
413
+
414
+
415
+ def end_tunnels(stage: pathlib.Path) -> None:
416
+ """The reverse tunnels :mod:`quality_loop.guest.screens` started for the
417
+ screens' pages: it records each one's pid on the stage, and the operator
418
+ that started them is gone by the run's end."""
419
+ for pid_file in (stage / "tunnels").glob("*.pid"):
420
+ try:
421
+ os.kill(int(pid_file.read_text()), 15)
422
+ except (OSError, ValueError):
423
+ pass
424
+
425
+
426
+ # --- the machines -----------------------------------------------------------------
427
+
428
+ class TartMachine:
429
+ """A Tart VM cloned from the base for this run, and deleted after it.
430
+
431
+ Its share is ``tart run --dir``, tagged with the last part of the share's
432
+ guest side (``/Volumes/My Shared Files/qa`` -> ``qa``). Shown in a window
433
+ when ``visible = true``; watch it, but do not type into it.
434
+ """
435
+
436
+ def __init__(self, guest: Guest, patience: float = 240.0):
437
+ self.guest, self.patience = guest, patience
438
+ self.base = guest.settings["tart"]
439
+ self.name = f"{self.base}-run-{os.getpid()}"
440
+ self.process = None
441
+
442
+ def _tart(self, *args, check=True) -> subprocess.CompletedProcess:
443
+ said = subprocess.run(["tart", *args], capture_output=True, text=True)
444
+ if check and said.returncode != 0:
445
+ raise GuestError(f"tart {' '.join(args)}: {said.stderr.strip()}")
446
+ return said
447
+
448
+ def __enter__(self):
449
+ self._tart("clone", self.base, self.name)
450
+ tag = posixpath.basename(self.guest.guest_share.rstrip("/"))
451
+ args = ["tart", "run", f"--dir={tag}:{self.guest.host_share}", self.name]
452
+ if not self.guest.settings.get("visible"):
453
+ args.insert(2, "--no-graphics")
454
+ self.process = subprocess.Popen(args, stdin=subprocess.DEVNULL,
455
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
456
+ deadline = time.monotonic() + self.patience
457
+ while time.monotonic() < deadline:
458
+ said = self._tart("ip", self.name, check=False)
459
+ if said.returncode == 0 and said.stdout.strip():
460
+ self.guest.hostname = said.stdout.strip()
461
+ break
462
+ time.sleep(2)
463
+ if self.guest.hostname is None or not self.guest.reachable(
464
+ max(10.0, deadline - time.monotonic())):
465
+ self.__exit__()
466
+ raise GuestError(f"the clone {self.name} of {self.base} did not come up")
467
+ print(f"vm: {self.name}, a clone of {self.base}, at {self.guest.hostname}")
468
+ return self
469
+
470
+ def __exit__(self, *exc):
471
+ self._tart("stop", self.name, check=False)
472
+ if self.process is not None:
473
+ try:
474
+ self.process.wait(30)
475
+ except subprocess.TimeoutExpired:
476
+ self.process.kill()
477
+ self._tart("delete", self.name, check=False)
478
+ self.guest.hostname = None
479
+ print(f"vm: {self.name} deleted")
480
+ return False
481
+
482
+
483
+ class VMwareMachine:
484
+ """A VMware Fusion VM reverted to a snapshot before the run.
485
+
486
+ A snapshot taken while the VM was running comes back running, signed in
487
+ and with SSH up, after `vmrun start`. The SMB share that was mounted on
488
+ the host does not survive the revert, so it is mounted again. The VM is
489
+ shut down afterwards, its share unmounted first: the next run reverts it
490
+ anyway, and a VM `vmrun` left running headless is one the Fusion app
491
+ cannot open. ``visible`` has no say here: `vmrun` can only resume it
492
+ without a window. ``vm_password`` is the VM's encryption password, which
493
+ `vmrun` needs for a VM with a vTPM.
494
+
495
+ Learnt on Fusion 26 with a vTPM-encrypted Windows 11 VM: `vmrun` cannot
496
+ linked-clone it ("Cannot read the virtual machine configuration file"),
497
+ nor snapshot it while it runs ("Authentication for encrypted virtual
498
+ machine failed") - take a running-state snapshot in the Fusion app, after
499
+ `vmrun suspend` (a headless VM does not show there).
500
+ """
501
+
502
+ VMRUN = "/Applications/VMware Fusion.app/Contents/Library/vmrun"
503
+
504
+ def __init__(self, guest: Guest, patience: float = 180.0):
505
+ self.guest, self.patience = guest, patience
506
+ settings = guest.settings
507
+ self.vmx = str(pathlib.Path(settings["vmx"]).expanduser())
508
+ self.snapshot = settings["snapshot"]
509
+ self.password = settings.get("vm_password")
510
+
511
+ def _vmrun(self, *args, check: bool = True) -> bool:
512
+ command = [self.VMRUN, "-T", "fusion"]
513
+ if self.password:
514
+ command += ["-vp", secret(self.password)]
515
+ # Into a file, not a pipe: `vmrun start` hands its stdout on to the
516
+ # vmware-vmx it starts, so a pipe would not close until the VM stops.
517
+ with tempfile.TemporaryFile("w+") as output:
518
+ said = subprocess.run(command + list(args), stdin=subprocess.DEVNULL,
519
+ stdout=output, stderr=subprocess.STDOUT, text=True)
520
+ output.seek(0)
521
+ if said.returncode != 0 and check:
522
+ raise GuestError(f"vmrun {args[0]}: {output.read().strip()}")
523
+ return said.returncode == 0
524
+
525
+ def __enter__(self):
526
+ self._vmrun("revertToSnapshot", self.vmx, self.snapshot)
527
+ # nogui: start with no argument means gui, which Fusion refuses for a
528
+ # VM a running-state snapshot left suspended ("The operation is not
529
+ # supported"). Opening Fusion afterwards shows the running VM.
530
+ self._vmrun("start", self.vmx, "nogui")
531
+ if not self.guest.reachable(self.patience):
532
+ raise GuestError(f"{self.guest.alias} did not answer over SSH after the revert "
533
+ f"to {self.snapshot!r}")
534
+ self._mount()
535
+ self._wait_for_the_clock()
536
+ print(f"vm: {self.guest.alias} reverted to {self.snapshot!r}")
537
+ return self
538
+
539
+ def _mount(self) -> None:
540
+ url = self.guest.settings.get("smb")
541
+ if not url:
542
+ return
543
+ mount = str(self.guest.host_share)
544
+ subprocess.run(["umount", mount], capture_output=True)
545
+ password = self.guest.settings.get("smb_password")
546
+ if password:
547
+ user, _, where = url.removeprefix("//").partition("@")
548
+ url = f"//{user}:{urllib.parse.quote(secret(password), safe='')}@{where}"
549
+ # -N: never prompt. Without smb_password, the Keychain's internet
550
+ # password for the server (Finder's "Remember") has to do.
551
+ said = subprocess.run(["mount_smbfs", "-N", url, mount],
552
+ capture_output=True, text=True, stdin=subprocess.DEVNULL)
553
+ if said.returncode != 0:
554
+ raise GuestError(f"could not mount the share on {mount}: {said.stderr.strip()}")
555
+
556
+ def _wait_for_the_clock(self, patience: float = 60.0) -> None:
557
+ """A snapshot comes back with the time it was taken; VMware Tools
558
+ sets it right within seconds. Wait for that, so the run's own record
559
+ of times means something."""
560
+ deadline = time.monotonic() + patience
561
+ while time.monotonic() < deadline:
562
+ said = self.guest.ssh("[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()"
563
+ if self.guest.windows else "date +%s")
564
+ try:
565
+ if abs(int(said.stdout.strip()) - time.time()) < 30:
566
+ return
567
+ except ValueError:
568
+ pass
569
+ time.sleep(3)
570
+ print("vm: the guest's clock is still off - carrying on")
571
+
572
+ def __exit__(self, *exc):
573
+ if self.guest.settings.get("smb"):
574
+ subprocess.run(["umount", str(self.guest.host_share)], capture_output=True)
575
+ # hard: the snapshot it is reverted to next time is all that counts.
576
+ if self._vmrun("stop", self.vmx, "hard", check=False):
577
+ print(f"vm: {self.guest.alias} stopped")
578
+ else:
579
+ print(f"vm: could not stop {self.guest.alias} - stop it before opening Fusion")
580
+ return False
@@ -0,0 +1,7 @@
1
+ """Scripts run in the guest, from a run's stage - never imported on the host.
2
+
3
+ ``launch.py`` starts a product's script in the desktop session with no console
4
+ window; ``screen_agent.py`` performs one screen operation there. Both use
5
+ only the standard library and the quality_loop files the stage carries
6
+ (`quality_loop.guest.Guest.stage`).
7
+ """
@@ -0,0 +1,35 @@
1
+ """A product's script in a Windows guest's desktop session, with no console.
2
+
3
+ Not run on the host. `quality_loop.guest.Guest.launch` has a scheduled task
4
+ run this with the image's pythonw.exe, the path of a JSON file as its one
5
+ argument. A batch file would do the same with less code, but cmd.exe opens a
6
+ console - on Windows 11 a Windows Terminal window - on the desktop the room
7
+ looks at, and an app that hands the foreground back would hand it to that.
8
+
9
+ What the batch file did is done here instead, from the JSON: the environment,
10
+ the search path in front of the interpreter's own, the working directory, and
11
+ stdout and stderr into the log. Then the script runs as __main__.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import runpy
17
+ import site
18
+ import sys
19
+
20
+ with open(sys.argv[1], encoding="utf-8") as f:
21
+ settings = json.load(f)
22
+
23
+ # pythonw has no stdout or stderr: the log is both.
24
+ log = open(settings["log"], "w", encoding="utf-8", buffering=1)
25
+ sys.stdout = sys.stderr = log
26
+
27
+ os.environ.update(settings.get("environ") or {})
28
+ if site.USER_SITE in sys.path:
29
+ sys.path.remove(site.USER_SITE)
30
+ sys.path[0:0] = settings.get("path") or []
31
+ if settings.get("cwd"):
32
+ os.chdir(settings["cwd"])
33
+ sys.argv = [settings["script"], *settings.get("argv", [])]
34
+
35
+ runpy.run_path(settings["script"], run_name="__main__")
@@ -0,0 +1,54 @@
1
+ """One screen operation of quality-loop's own OS backend, run in the guest.
2
+
3
+ Not run on the host. `quality_loop.guest.screens`, the screen backend the
4
+ operator uses when the product runs in a VM, writes a request on the run's
5
+ stage and runs this there: over SSH on macOS, from a scheduled task in the
6
+ desktop session on Windows (a window opened from SSH lands where nobody sees
7
+ it). The request names the operation, its arguments, and where to write the
8
+ answer; the backend waits for that file.
9
+
10
+ The operations are quality-loop's own (quality_loop.screens._mac and
11
+ _windows, copied onto the stage), so a page or a terminal in the guest is put
12
+ up and recognised exactly as on a desktop the operator shares.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import traceback
19
+
20
+ # <stage>/ql/quality_loop/guest/boot/screen_agent.py -> <stage>/ql
21
+ QL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
22
+ sys.path.insert(0, QL)
23
+
24
+ with open(sys.argv[1], encoding="utf-8") as f:
25
+ request = json.load(f)
26
+
27
+ try:
28
+ from quality_loop.screens import ScreenError
29
+ if sys.platform == "darwin":
30
+ from quality_loop.screens import _mac as os_backend
31
+ else:
32
+ from quality_loop.screens import _windows as os_backend
33
+ op = request["op"]
34
+ if op == "open_page":
35
+ value = os_backend.open_page(request["url"], request["title"], request["patience"])
36
+ elif op == "open_terminal":
37
+ value = os_backend.open_terminal(request["command"], request["title"],
38
+ request["patience"])
39
+ elif op == "close":
40
+ os_backend.close(request["kind"], request["handle"])
41
+ value = None
42
+ else:
43
+ raise ValueError(f"no operation {op!r}")
44
+ answer = {"ok": True, "value": value}
45
+ except ScreenError as error:
46
+ answer = {"ok": False, "error": str(error)}
47
+ except Exception:
48
+ answer = {"ok": False, "error": traceback.format_exc()}
49
+
50
+ # Written whole, then renamed: the host polls for the name.
51
+ partial = request["result"] + ".partial"
52
+ with open(partial, "w", encoding="utf-8") as f:
53
+ json.dump(answer, f)
54
+ os.replace(partial, request["result"])
@@ -0,0 +1,138 @@
1
+ """The operator's screens, put up in the VM the product runs in.
2
+
3
+ An adapter whose app runs in a guest returns this file from
4
+ ``screen_backend()``, with :func:`settings` as its settings::
5
+
6
+ def screen_backend(self):
7
+ return {"path": quality_loop.guest.screens.__file__,
8
+ "settings": quality_loop.guest.screens.settings(guest, stage)}
9
+
10
+ and quality-loop loads it (`quality_loop.screens.load_backend`) in place of
11
+ this machine's own, so `screen_open` puts a page or a terminal up on the
12
+ guest's desktop - where the app reads the screen - and not on the host's.
13
+
14
+ Each operation is quality-loop's own (screens._mac / _windows), run in the
15
+ guest by ``boot/screen_agent.py`` from the run's stage: over SSH on macOS,
16
+ from a scheduled task in the desktop session on Windows. A request and its
17
+ answer are files on the share.
18
+
19
+ Pages are served on the host's 127.0.0.1 by the runner. A reverse tunnel
20
+ (`ssh -R`, same port) makes that address the guest's too, so the URL, and the
21
+ page's POSTed events, work unchanged. One tunnel per port, started by the
22
+ first page that needs it; its pid is kept on the stage, and the run's end
23
+ (`quality_loop.guest.end_tunnels`) ends it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import pathlib
31
+ import shutil
32
+ import time
33
+ import urllib.parse
34
+ import uuid
35
+
36
+ from quality_loop.guest import Guest, sh_quote
37
+ from quality_loop.screens import ScreenError
38
+
39
+ _guest: Guest | None = None
40
+ _stage: pathlib.Path | None = None
41
+ _terminal_python: str | None = None
42
+
43
+
44
+ def settings(guest: Guest, stage: pathlib.Path, terminal_python: str | None = None) -> dict:
45
+ """What :func:`configure` needs, from the adapter's live guest and stage.
46
+ ``terminal_python`` runs a terminal screen's script in the guest; the
47
+ image's interpreter by default."""
48
+ return dict(guest.to_settings(), stage=str(stage),
49
+ terminal_python=terminal_python or guest.python)
50
+
51
+
52
+ def configure(settings: dict) -> None:
53
+ global _guest, _stage, _terminal_python
54
+ _guest = Guest.from_screen_settings(settings)
55
+ _stage = pathlib.Path(settings["stage"])
56
+ _terminal_python = settings.get("terminal_python") or _guest.python
57
+
58
+
59
+ def _call(request: dict, patience: float):
60
+ """Run one operation in the guest and answer its value."""
61
+ requests = _stage / "screens"
62
+ requests.mkdir(exist_ok=True)
63
+ name = uuid.uuid4().hex[:12]
64
+ asked, answered = requests / f"{name}.json", requests / f"{name}-answer.json"
65
+ request = dict(request, result=_guest.path(answered))
66
+ asked.write_text(json.dumps(request), encoding="utf-8")
67
+ agent = _guest.boot_script(_stage, "screen_agent.py")
68
+ task = f"ql-screen-{name}"
69
+ try:
70
+ if _guest.windows:
71
+ try:
72
+ _guest.run_in_session(_stage, task, _guest.windowless(_guest.python),
73
+ agent, _guest.path(asked))
74
+ except Exception as error:
75
+ raise ScreenError(str(error)) from None
76
+ else:
77
+ # Synchronous over SSH; Safari and Terminal are driven through
78
+ # Apple Events, which the guest's base image has allowed for SSH.
79
+ said = _guest.ssh(f"{sh_quote(_guest.python)} {sh_quote(_guest.path(agent))} "
80
+ f"{sh_quote(_guest.path(asked))}", timeout=patience + 120)
81
+ if said.returncode != 0 and not answered.exists():
82
+ raise ScreenError(f"the VM did not run it: {said.stderr.strip()[:500]}")
83
+ deadline = time.monotonic() + patience + 60
84
+ while not answered.exists():
85
+ if time.monotonic() > deadline:
86
+ raise ScreenError(f"no answer from the VM within {patience + 60:.0f}s")
87
+ time.sleep(0.5)
88
+ answer = json.loads(answered.read_text(encoding="utf-8"))
89
+ finally:
90
+ if _guest.windows:
91
+ _guest.forget_task(task)
92
+ for leftover in (asked, answered, _stage / f"{task}.xml"):
93
+ leftover.unlink(missing_ok=True)
94
+ if not answer["ok"]:
95
+ raise ScreenError(f"in the VM: {answer['error']}")
96
+ return answer["value"]
97
+
98
+
99
+ def _tunnel(port: int) -> None:
100
+ """The host's 127.0.0.1:port as the guest's, once per port."""
101
+ tunnels = _stage / "tunnels"
102
+ tunnels.mkdir(exist_ok=True)
103
+ pid_file = tunnels / f"{port}.pid"
104
+ if pid_file.exists():
105
+ try:
106
+ os.kill(int(pid_file.read_text()), 0)
107
+ return
108
+ except (OSError, ValueError):
109
+ pass
110
+ process = _guest.reverse(port)
111
+ pid_file.write_text(str(process.pid))
112
+ time.sleep(2)
113
+ if process.poll() is not None:
114
+ pid_file.unlink(missing_ok=True)
115
+ raise ScreenError(f"could not forward the pages' port {port} into the VM")
116
+
117
+
118
+ def open_page(url: str, title: str, patience: float):
119
+ _tunnel(urllib.parse.urlsplit(url).port)
120
+ return _call({"op": "open_page", "url": url, "title": title, "patience": patience},
121
+ patience)
122
+
123
+
124
+ def open_terminal(command: list[str], title: str, patience: float):
125
+ # The runner's command is this machine's: its interpreter, and a copy of
126
+ # the script outside the checkout. The guest gets its own interpreter and
127
+ # a copy of the script on the share.
128
+ script = pathlib.Path(command[-1])
129
+ scripts = _stage / "scripts"
130
+ scripts.mkdir(exist_ok=True)
131
+ shutil.copy2(script, scripts / script.name)
132
+ guest_command = [_terminal_python, _guest.path(scripts / script.name)]
133
+ return _call({"op": "open_terminal", "command": guest_command, "title": title,
134
+ "patience": patience}, patience)
135
+
136
+
137
+ def close(kind: str, handle) -> None:
138
+ _call({"op": "close", "kind": kind, "handle": handle}, 15)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quality-loop
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Measure what a product teaches - its skill, tools or manual - with isolated agent runs of real tasks, reported by an operator agent
5
5
  Author: craftware
6
6
  License-Expression: MIT
@@ -110,6 +110,8 @@ under a policy the person sets. See [Running the loop](https://github.com/crftwr
110
110
  launch command and a redirected home up to evidence tools of its own.
111
111
  - [Reports](https://github.com/crftwr/quality-loop/blob/main/docs/reports.md): problems, severities, kinds and ids, the
112
112
  reproduction record, and `FINDINGS.md` / `findings.json`.
113
+ - [Running the app in a VM](https://github.com/crftwr/quality-loop/blob/main/docs/guests.md): one image per OS,
114
+ the guest over SSH, a clean machine every run, screens in the guest.
113
115
  - [Isolation and permissions](https://github.com/crftwr/quality-loop/blob/main/docs/isolation.md): what keeps the room, the
114
116
  operator and the conductor in bounds, why each rule exists, and the known limits.
115
117
  - [Development](https://github.com/crftwr/quality-loop/blob/main/docs/development.md): tests, live tests, releasing.
@@ -33,6 +33,11 @@ quality_loop/drive/_mac.py
33
33
  quality_loop/drive/_windows.py
34
34
  quality_loop/drive/keys.py
35
35
  quality_loop/drive/terminal.py
36
+ quality_loop/guest/__init__.py
37
+ quality_loop/guest/screens.py
38
+ quality_loop/guest/boot/__init__.py
39
+ quality_loop/guest/boot/launch.py
40
+ quality_loop/guest/boot/screen_agent.py
36
41
  quality_loop/screens/__init__.py
37
42
  quality_loop/screens/_mac.py
38
43
  quality_loop/screens/_windows.py
@@ -41,6 +46,7 @@ quality_loop/skill/references/filing.md
41
46
  quality_loop/skill/references/setup.md
42
47
  tests/test_core.py
43
48
  tests/test_drive.py
49
+ tests/test_guest.py
44
50
  tests/test_loop.py
45
51
  tests/test_mcp.py
46
52
  tests/test_operator.py
@@ -0,0 +1,215 @@
1
+ """The guest layer, without a VM: SSH is faked, and what runs in the guest -
2
+ the boot scripts - is run here, on the stage it would read in the guest.
3
+
4
+ A real VM is checked by the products that use it (Keyhac's and XeFM's qa/).
5
+ """
6
+
7
+ import json
8
+ import pathlib
9
+ import subprocess
10
+ import sys
11
+
12
+ import pytest
13
+
14
+ from quality_loop import guest as guests
15
+ from quality_loop.guest import Guest, GuestError
16
+ from quality_loop.guest import screens as guest_screens
17
+ from quality_loop.screens import ScreenError
18
+
19
+
20
+ @pytest.fixture
21
+ def share(tmp_path):
22
+ share = tmp_path / "share"
23
+ share.mkdir()
24
+ return share.resolve()
25
+
26
+
27
+ def _guest(share, windows=False, **settings):
28
+ guest = Guest("qa-test", share, r"C:\qa" if windows else str(share),
29
+ {"python": sys.executable, **settings})
30
+ guest._windows = windows
31
+ return guest
32
+
33
+
34
+ class FakeSSH:
35
+ """Records commands; answers with ``answer(command)``."""
36
+
37
+ def __init__(self, answer=lambda command: ("", 0)):
38
+ self.commands, self.answer = [], answer
39
+
40
+ def __call__(self, command, timeout=60):
41
+ self.commands.append(command)
42
+ out, code = self.answer(command)
43
+ return subprocess.CompletedProcess([], code, out, "")
44
+
45
+
46
+ # --- settings ---------------------------------------------------------------------
47
+
48
+ def test_settings_by_alias_from_the_named_file(tmp_path, monkeypatch, share):
49
+ where = tmp_path / "guests.toml"
50
+ where.write_text(f'[qa-win]\nshare = "{share}=C:\\\\qa"\npython = "C:\\\\P\\\\python.exe"\n')
51
+ monkeypatch.setenv("QUALITY_LOOP_GUESTS", str(where))
52
+ assert guests.settings_for("qa-win")["python"] == r"C:\P\python.exe"
53
+ assert guests.settings_for("nobody") == {}
54
+ monkeypatch.setenv("QA_GUEST", "qa-win")
55
+ guest = Guest.from_environment("QA_GUEST")
56
+ assert (guest.host_share, guest.guest_share) == (share, r"C:\qa")
57
+ assert Guest.from_environment("UNSET_QA_GUEST") is None
58
+
59
+
60
+ def test_a_share_needs_both_sides_and_an_existing_host_side(share):
61
+ with pytest.raises(GuestError, match="share ="):
62
+ Guest.from_settings("x", {"share": str(share)})
63
+ with pytest.raises(GuestError, match="not a directory"):
64
+ Guest.from_settings("x", {"share": f"{share}/missing=C:\\qa"})
65
+
66
+
67
+ def test_python_must_be_named(share):
68
+ guest = Guest("x", share, "C:\\qa", {})
69
+ with pytest.raises(GuestError, match="python ="):
70
+ guest.python
71
+
72
+
73
+ def test_a_keychain_secret_is_read_and_anything_else_passes(monkeypatch):
74
+ assert guests.secret("plain") == "plain"
75
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(
76
+ a, 0, "s3cret\n", ""))
77
+ assert guests.secret("keychain:qa-vm") == "s3cret"
78
+
79
+
80
+ # --- paths and quoting ---------------------------------------------------------------
81
+
82
+ def test_a_host_path_as_the_guest_sees_it(share):
83
+ (share / "stage").mkdir()
84
+ assert _guest(share, windows=True).path(share / "stage" / "a.json") == r"C:\qa\stage\a.json"
85
+ assert _guest(share).path(share / "stage") == f"{share}/stage"
86
+
87
+
88
+ def test_the_task_keeps_program_and_arguments_apart():
89
+ xml = guests.task_xml(r"C:\P\pythonw.exe", '"-s" "C:\\qa\\a & b.py"')
90
+ assert "<Command>C:\\P\\pythonw.exe</Command>" in xml
91
+ assert "a &amp; b.py" in xml and "InteractiveToken" in xml
92
+
93
+
94
+ def test_quoting():
95
+ assert guests.ps_quote("it's") == "'it''s'"
96
+ assert guests.sh_quote("it's") == "'it'\\''s'"
97
+
98
+
99
+ def test_windowless_is_pythonw_beside_it(share):
100
+ assert _guest(share, windows=True).windowless(r"C:\P\python.exe") == r"C:\P\pythonw.exe"
101
+ assert _guest(share).windowless("/usr/bin/python3") == "/usr/bin/python3"
102
+
103
+
104
+ # --- the stage, the venv, the session --------------------------------------------------
105
+
106
+ def test_the_stage_carries_the_screens_and_the_boot_scripts(share):
107
+ stage = _guest(share).stage()
108
+ ql = stage / "ql" / "quality_loop"
109
+ assert (ql / "__init__.py").exists() and (ql / "screens" / "_windows.py").exists()
110
+ assert _guest(share).boot_script(stage, "screen_agent.py").exists()
111
+ assert not list(stage.rglob("__pycache__"))
112
+
113
+
114
+ def test_the_venv_is_made_in_the_guest_with_the_cache_on_the_share(share, monkeypatch):
115
+ guest = _guest(share, windows=True, python=r"C:\Python314\python.exe")
116
+ ssh = FakeSSH(lambda c: ("C:\\Users\\qa\\AppData\\Local\\ql-venv-x\\Scripts\\python.exe\n", 0))
117
+ monkeypatch.setattr(guest, "ssh", ssh)
118
+ stage = guest.stage()
119
+ python = guest.venv(stage, ["pillow>=9", "truststore"])
120
+ assert python.endswith(r"Scripts\python.exe")
121
+ (command,) = ssh.commands
122
+ assert r"'C:\Python314\python.exe' -m venv" in command
123
+ assert r"--cache-dir 'C:\qa\.pip-cache'" in command
124
+ assert (share / ".pip-cache").is_dir()
125
+ assert (stage / "requirements.txt").read_text() == "pillow>=9\ntruststore\n"
126
+
127
+
128
+ def test_a_failed_install_says_what_pip_said(share, monkeypatch):
129
+ guest = _guest(share)
130
+ monkeypatch.setattr(guest, "ssh", FakeSSH(lambda c: ("No matching distribution", 1)))
131
+ with pytest.raises(GuestError, match="No matching distribution"):
132
+ guest.venv(guest.stage(), ["nothing-like-this"])
133
+
134
+
135
+ def test_run_in_session_is_a_task_and_launch_hands_it_the_json(share, monkeypatch):
136
+ guest = _guest(share, windows=True)
137
+ ssh = FakeSSH(lambda c: ("SUCCESS: Attempted to run the scheduled task", 0))
138
+ monkeypatch.setattr(guest, "ssh", ssh)
139
+ stage = guest.stage()
140
+ guest.launch(stage, "ql-app", r"C:\P\pythonw.exe", script=r"C:\qa\app.py",
141
+ log=stage / "app.log", environ={"HOME": r"C:\qa\home"}, argv=["--x"])
142
+ (command,) = ssh.commands
143
+ assert "schtasks /create /tn 'ql-app'" in command and "schtasks /run" in command
144
+ launch = json.loads((stage / "ql-app.json").read_text())
145
+ assert launch["environ"] == {"HOME": r"C:\qa\home"} and launch["argv"] == ["--x"]
146
+ assert "launch.py" in (stage / "ql-app.xml").read_text(encoding="utf-16")
147
+
148
+
149
+ def test_run_in_session_says_when_the_task_did_not_start(share, monkeypatch):
150
+ guest = _guest(share, windows=True)
151
+ monkeypatch.setattr(guest, "ssh", FakeSSH(lambda c: ("ERROR: Access is denied.", 0)))
152
+ with pytest.raises(GuestError, match="Access is denied"):
153
+ guest.run_in_session(guest.stage(), "t", "pythonw.exe", share / "x.py")
154
+ with pytest.raises(GuestError, match="Windows guest"):
155
+ _guest(share).run_in_session(share, "t", "python", share / "x.py")
156
+
157
+
158
+ def test_launch_runs_the_script_with_its_environment_path_and_log(share, tmp_path):
159
+ """boot/launch.py itself, run here as the task would run it there."""
160
+ stage = _guest(share).stage()
161
+ lib = tmp_path / "lib"
162
+ lib.mkdir()
163
+ (lib / "helper.py").write_text("WHO = 'staged'\n")
164
+ script = tmp_path / "app.py"
165
+ script.write_text("import os, sys, helper\n"
166
+ "print(helper.WHO, os.environ['QL_X'], sys.argv[1:], os.getcwd())\n")
167
+ settings = {"log": str(stage / "app.log"), "script": str(script),
168
+ "environ": {"QL_X": "set"}, "path": [str(lib)], "cwd": str(tmp_path),
169
+ "argv": ["--flag"]}
170
+ (stage / "launch.json").write_text(json.dumps(settings))
171
+ subprocess.run([sys.executable, "-s", str(_guest(share).boot_script(stage, "launch.py")),
172
+ str(stage / "launch.json")], check=True)
173
+ assert (stage / "app.log").read_text().strip() == f"staged set ['--flag'] {tmp_path}"
174
+
175
+
176
+ # --- the screen backend ----------------------------------------------------------------
177
+
178
+ def test_the_backend_runs_the_agent_in_the_guest_and_reads_its_answer(share, monkeypatch):
179
+ """macOS shape: the agent over SSH - here, run locally on the stage."""
180
+ guest = _guest(share)
181
+ stage = guest.stage()
182
+ settings = guest_screens.settings(guest, stage)
183
+ assert settings["terminal_python"] == sys.executable
184
+ guest_screens.configure(settings)
185
+
186
+ def run_agent(command, timeout=60):
187
+ agent = guest.boot_script(stage, "screen_agent.py")
188
+ asked = command.rsplit(" ", 1)[1].strip("'")
189
+ return subprocess.run([sys.executable, str(agent), asked],
190
+ capture_output=True, text=True)
191
+
192
+ monkeypatch.setattr(guest_screens._guest, "ssh", run_agent)
193
+ with pytest.raises(ScreenError, match="no operation 'paint'"):
194
+ guest_screens._call({"op": "paint"}, patience=5)
195
+ assert not list((stage / "screens").iterdir()), "the request and answer are cleaned up"
196
+
197
+
198
+ def test_a_terminal_script_is_staged_and_run_with_the_guest_python(share, monkeypatch, tmp_path):
199
+ guest = _guest(share)
200
+ stage = guest.stage()
201
+ guest_screens.configure(guest_screens.settings(guest, stage, terminal_python="/g/python"))
202
+ seen = {}
203
+ monkeypatch.setattr(guest_screens, "_call", lambda request, patience: seen.update(request))
204
+ script = tmp_path / "deploy.py"
205
+ script.write_text("print('x')\n")
206
+ guest_screens.open_terminal(["/host/python", str(script)], "deploy", 5)
207
+ assert seen["command"] == ["/g/python", str(stage / "scripts" / "deploy.py")]
208
+ assert (stage / "scripts" / "deploy.py").exists()
209
+
210
+
211
+ def test_machine_follows_the_settings(share):
212
+ assert isinstance(_guest(share, tart="base").machine(), guests.TartMachine)
213
+ assert isinstance(_guest(share, vmx="/v.vmx", snapshot="s").machine(), guests.VMwareMachine)
214
+ with _guest(share).machine():
215
+ pass
File without changes
File without changes