Skip to content

Commit 0da7b34

Browse files
committed
fix: make validating VM lifecycle fail closed
1 parent 2f5be8d commit 0da7b34

9 files changed

Lines changed: 312 additions & 49 deletions

.github/actionlint.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
self-hosted-runner:
2+
labels:
3+
- openadapt-parallels

.github/workflows/validate-claims.yml

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ on:
3333

3434
concurrency:
3535
group: validate-claims-${{ github.workflow }}-${{ github.ref }}
36-
cancel-in-progress: true
36+
# A pull-request structure check is safe to supersede. A physical Parallels
37+
# run is not: cancellation could interrupt its exact-snapshot cleanup.
38+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
3739

3840
permissions:
3941
contents: read
@@ -90,7 +92,7 @@ jobs:
9092
# These selected tests require macOS, local Parallels, a running configured
9193
# Windows VM, and a known base snapshot. They cannot run on ubuntu-latest or
9294
# against an unrelated hosted VM endpoint. A repository owner enables the
93-
# job only after a self-hosted runner has all three labels below and the two
95+
# job only after a self-hosted runner has all four labels below and the two
9496
# required repository variables are configured.
9597
#
9698
# A skipped job means "not proven this run" and produces no report. A green
@@ -101,8 +103,11 @@ jobs:
101103
if: >-
102104
${{ vars.OPENADAPT_PARALLELS_VALIDATION_ENABLED == 'true' &&
103105
(github.event_name == 'schedule' || inputs.run_infra == true) }}
104-
runs-on: [self-hosted, macos, arm64]
106+
runs-on: [self-hosted, macos, arm64, openadapt-parallels]
105107
timeout-minutes: 90
108+
concurrency:
109+
group: openadapt-parallels-validation
110+
cancel-in-progress: false
106111
env:
107112
OAFLOW_PARALLELS_VM_UUID: ${{ vars.OAFLOW_PARALLELS_VM_UUID }}
108113
OAFLOW_PARALLELS_BASE_SNAPSHOT_ID: ${{ vars.OAFLOW_PARALLELS_BASE_SNAPSHOT_ID }}
@@ -135,15 +140,29 @@ jobs:
135140
- name: Install validation dependencies
136141
run: python -m pip install -e .[dev]
137142

143+
- name: Prove configured VM and base are current before mutation
144+
run: |
145+
python -c '
146+
import os
147+
from openadapt_flow.backends.parallels_vm import ParallelsVM
148+
vm = ParallelsVM(os.environ["OAFLOW_PARALLELS_VM_UUID"])
149+
vm.require_host_free_space(
150+
storage_path=os.environ["OAFLOW_PARALLELS_STORAGE_PATH"]
151+
)
152+
vm.require_current_snapshot(
153+
os.environ["OAFLOW_PARALLELS_BASE_SNAPSHOT_ID"]
154+
)
155+
'
156+
138157
- name: Run scoped desktop and pixel evidence
139158
env:
140159
OAFLOW_PARALLELS_E2E: "1"
141160
OAFLOW_CITRIX_PIXEL_E2E: "1"
142161
run: |
143162
mkdir -p runs
144163
python -m pytest -q \
145-
tests/e2e/test_parallels_desktop_e2e.py \
146164
tests/e2e/test_citrix_pixel_e2e.py \
165+
tests/e2e/test_parallels_desktop_e2e.py \
147166
--junitxml=runs/validating-junit.xml --basetemp=runs/ci
148167
149168
- name: Require every selected evidence file to pass

docs/desktop_windows_runbook.md

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
How to bring the **desktop** path to parity with the **web** path — record →
44
compile → replay with the structural (UIA) + vision ladder, identity, and
5-
effect verification — and run the **snapshot-safe live proof** in one pass on a
6-
machine with the Parallels Windows 11 VM.
5+
effect verification — and run the **snapshot-safe live proof** in three counted
6+
trials on a machine with the Parallels Windows 11 VM.
77

88
This is the operator runbook. The design/rationale lives in
99
`openadapt_flow/backends/win_agent/README.md` (the agent) and
@@ -126,25 +126,34 @@ fire.
126126

127127
---
128128

129-
## 4. Run the snapshot-safe live proof (one pass)
129+
## 4. Run the snapshot-safe live proof (three counted trials)
130130

131131
The opt-in e2e (`tests/e2e/test_parallels_desktop_e2e.py`) does the whole loop
132-
against the built-in Windows **Calculator** (deterministic, no PHI): snapshot →
133-
ensure VM up → launch the agent in session 1 → record→compile→replay via
134-
`WindowsBackend` → assert the UIA structural rung fires and the run completes →
135-
**revert to the snapshot**.
132+
against the deterministic Patient Notes fixture: prove one exact configured
133+
base snapshot is current → revert to that base → ensure the VM is running →
134+
create one exact trial snapshot → launch the typed agent in session 1 →
135+
record→compile→replay via `WindowsBackend` → assert the UIA structural rung and
136+
independent database oracle → restore and verify the configured base → delete
137+
only the exact trial snapshot → suspend and verify the VM. It repeats this
138+
contract three times.
136139

137140
```bash
138141
# On the Mac with the Parallels VM:
139-
OAFLOW_PARALLELS_E2E=1 pytest -q tests/e2e/test_parallels_desktop_e2e.py
140-
# Optional: OAFLOW_PARALLELS_VM_UUID='{...}' to target a different VM.
142+
export OAFLOW_PARALLELS_E2E=1
143+
export OAFLOW_PARALLELS_VM_UUID='{exact-vm-uuid}'
144+
export OAFLOW_PARALLELS_BASE_SNAPSHOT_ID='{exact-base-snapshot-uuid}'
145+
export OAFLOW_WINDOWS_UIA_CANDIDATE_COMMIT='40-character-git-commit'
146+
export OAFLOW_PARALLELS_STORAGE_PATH="$PWD"
147+
pytest -q tests/e2e/test_parallels_desktop_e2e.py
141148
```
142149

143-
**Snapshot safety, guaranteed:** the test takes a **fresh** snapshot before it
144-
touches the guest and reverts to it in a `finally` block. It **never deletes**
145-
the VM or any snapshot, and it is **skipped entirely** unless
146-
`OAFLOW_PARALLELS_E2E=1`. Always confirm your VM has a known-good snapshot before
147-
running anything against it.
150+
**Snapshot safety contract:** the test refuses before mutation unless the exact
151+
configured base exists and is current. It retains each created trial snapshot
152+
ID in memory. Cleanup restores and verifies the base before it deletes only
153+
that exact ID. A restore, verification, deletion, evidence-write, or final
154+
suspend failure rejects the trial. The harness never uses a snapshot name,
155+
wildcard, child-recursive delete, or VM delete operation. It is skipped entirely
156+
unless `OAFLOW_PARALLELS_E2E=1`.
148157

149158
---
150159

openadapt_flow/backends/parallels_vm.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,29 @@ def list_snapshots(self) -> list[SnapshotInfo]:
278278
out.append(SnapshotInfo(snapshot_id=sid, current=bool(star)))
279279
return out
280280

281+
def require_current_snapshot(self, snapshot_id: str) -> None:
282+
"""Prove one exact preserved snapshot exists and is current.
283+
284+
Qualification callers use this read-only check before they resume,
285+
focus, snapshot, deploy, or actuate on a dedicated VM. It prevents a
286+
run from preserving and later deleting an unrelated current state.
287+
"""
288+
289+
snapshot_pattern = (
290+
r"\{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
291+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}"
292+
)
293+
if re.fullmatch(snapshot_pattern, snapshot_id) is None:
294+
raise ValueError("snapshot_id must be one exact braced UUID")
295+
snapshots = self.list_snapshots()
296+
match = next(
297+
(item for item in snapshots if item.snapshot_id == snapshot_id), None
298+
)
299+
if match is None:
300+
raise ParallelsError("preserved base snapshot is missing")
301+
if not match.current:
302+
raise ParallelsError("preserved base snapshot is not current")
303+
281304
def revert(self, snapshot_id: str) -> None:
282305
"""Revert to a snapshot (the per-run clean-state reset)."""
283306
self._run(["snapshot-switch", self.uuid, "-i", snapshot_id])

public-artifacts.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
"path": ".github/ISSUE_TEMPLATE/feature_request.yml",
7272
"sha256": "bb032fd5942477be558870dbf2c6615d258b6600ac2b389c5c4bf7c55b856da6"
7373
},
74+
{
75+
"path": ".github/actionlint.yaml",
76+
"sha256": "f58f146d55a022c530f1dd35e5da6a2e9ea0cd5cbcac0e6b0e50995a8b45cf20"
77+
},
7478
{
7579
"path": ".github/dependabot.yml",
7680
"sha256": "40a11fc00e1010db80d8788ee8c6125b51b14d642be018cddb761ea209f66095"
@@ -121,7 +125,7 @@
121125
},
122126
{
123127
"path": ".github/workflows/validate-claims.yml",
124-
"sha256": "75265f62b9606ee7eca54a989f72d09dd7a0f16016e5883fd723a09d825d990b"
128+
"sha256": "368accbf68954e8e481fc6d7d36229b9cb70c267ec302e01657aa6537900a9e6"
125129
},
126130
{
127131
"path": ".pre-commit-config.yaml",

tests/e2e/test_citrix_pixel_e2e.py

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@
3434
* Requires macOS with Screen-Recording AND Accessibility granted to the
3535
driving app; SKIPS (never fails, never fabricates) when input cannot be
3636
delivered — a dropped synthetic click must never look like success.
37-
* SNAPSHOT-FIRST, REVERT-AFTER; NEVER deletes the VM or ANY snapshot.
38-
* Requires the Parallels VM window to be open + resumable; SKIPS otherwise.
39-
40-
Env overrides: ``OAFLOW_PARALLELS_VM_UUID``, ``OAFLOW_CITRIX_WINDOW_TITLE``
41-
(default "Windows 11").
37+
* Requires one exact preserved base snapshot to be current before mutation.
38+
* Creates one exact owned snapshot, restores the base, verifies it, and
39+
deletes only that owned snapshot. A cleanup failure fails the proof.
40+
* Requires the Parallels VM window to be open + resumable.
41+
42+
Env overrides: ``OAFLOW_PARALLELS_VM_UUID``,
43+
``OAFLOW_PARALLELS_BASE_SNAPSHOT_ID``, ``OAFLOW_PARALLELS_STORAGE_PATH``, and
44+
``OAFLOW_CITRIX_WINDOW_TITLE`` (default "Windows 11").
4245
"""
4346

4447
from __future__ import annotations
@@ -63,6 +66,8 @@
6366

6467
_SCRIPT_DIR = Path(__file__).resolve().parents[2] / "scripts" / "desktop"
6568
WINDOW_TITLE = os.environ.get("OAFLOW_CITRIX_WINDOW_TITLE", "Windows 11")
69+
BASE_SNAPSHOT_ENV = "OAFLOW_PARALLELS_BASE_SNAPSHOT_ID"
70+
HOST_STORAGE_PATH_ENV = "OAFLOW_PARALLELS_STORAGE_PATH"
6671

6772

6873
# -- environment guards (skip cleanly, never fail spuriously) ----------------
@@ -141,6 +146,52 @@ def _db_get(vm, pid: int) -> dict:
141146
return json.loads((r.stdout or "null").strip() or "null")
142147

143148

149+
def _restore_pixel_vm(
150+
vm,
151+
*,
152+
base_snapshot_id: str,
153+
owned_snapshot_id: str | None,
154+
vm_touched: bool,
155+
) -> None:
156+
"""Restore the exact base, delete only the owned snapshot, and suspend."""
157+
158+
from openadapt_flow.backends.parallels_vm import ParallelsError
159+
160+
if owned_snapshot_id is None and not vm_touched:
161+
return
162+
cleanup_errors: list[Exception] = []
163+
if owned_snapshot_id is not None:
164+
try:
165+
vm.restore_base_and_delete_owned_snapshot(
166+
base_snapshot_id=base_snapshot_id,
167+
owned_snapshot_id=owned_snapshot_id,
168+
)
169+
except Exception as exc: # noqa: BLE001
170+
cleanup_errors.append(exc)
171+
elif vm_touched:
172+
try:
173+
vm.revert(base_snapshot_id)
174+
except Exception as exc: # noqa: BLE001
175+
cleanup_errors.append(exc)
176+
try:
177+
state = vm.status()
178+
if state in {"running", "paused"}:
179+
vm.suspend()
180+
state = vm.status()
181+
if state != "suspended":
182+
raise ParallelsError(
183+
f"preserved base did not finish suspended (state={state!r})"
184+
)
185+
vm.require_current_snapshot(base_snapshot_id)
186+
except Exception as exc: # noqa: BLE001
187+
cleanup_errors.append(exc)
188+
if cleanup_errors:
189+
details = "; ".join(repr(error) for error in cleanup_errors)
190+
raise RuntimeError(
191+
f"pixel proof failed to restore its exact base: {details}"
192+
) from cleanup_errors[0]
193+
194+
144195
# -- the proof ---------------------------------------------------------------
145196

146197

@@ -151,7 +202,10 @@ def test_citrix_pixel_only_record_replay_identity_verify_halt(tmp_path) -> None:
151202
record_desktop_demo,
152203
structural_armed_coverage,
153204
)
154-
from openadapt_flow.backends.parallels_vm import DEFAULT_VM_UUID, ParallelsVM
205+
from openadapt_flow.backends.parallels_vm import (
206+
DEFAULT_VM_UUID,
207+
ParallelsVM,
208+
)
155209
from openadapt_flow.backends.remote_display import (
156210
RemoteDisplayBackend,
157211
RemoteDisplayError,
@@ -162,29 +216,46 @@ def test_citrix_pixel_only_record_replay_identity_verify_halt(tmp_path) -> None:
162216
from openadapt_flow.runtime.effects.onscreen import OnScreenReadbackVerifier
163217

164218
uuid = os.environ.get("OAFLOW_PARALLELS_VM_UUID", DEFAULT_VM_UUID)
219+
base_snapshot_id = os.environ.get(BASE_SNAPSHOT_ENV)
220+
if not base_snapshot_id:
221+
raise RuntimeError(
222+
f"{BASE_SNAPSHOT_ENV} is required for snapshot-safe qualification"
223+
)
165224
vm = ParallelsVM(uuid)
166-
167-
# The VM must already be running (Parallels Standard cannot prlctl-start; a
168-
# suspended VM is resumed by the operator or the GUI play button). Skip
169-
# cleanly rather than fail if it is not reachable.
170-
if vm.status() != "running" or not _guest_ready(vm):
171-
pytest.skip("Parallels VM not running / guest tools unreachable")
172-
173-
backend = RemoteDisplayBackend(
174-
owner_substr="Parallels Desktop", title_substr=WINDOW_TITLE
175-
)
225+
snap_id: str | None = None
226+
vm_touched = False
227+
active_error: BaseException | None = None
176228
try:
177-
backend.ensure_foreground()
178-
except RemoteDisplayError as e:
179-
pytest.skip(f"remote-display client window not foregroundable: {e}")
229+
storage_path = os.environ.get(HOST_STORAGE_PATH_ENV, os.getcwd())
230+
vm.require_host_free_space(storage_path=storage_path)
231+
# This read-only check must pass before the first mutation. Reverting
232+
# the same current id then binds the live working state to that exact
233+
# base before resume, focus, snapshot, deployment, or actuation.
234+
vm.require_current_snapshot(base_snapshot_id)
235+
vm_touched = True
236+
vm.revert(base_snapshot_id)
237+
vm.require_current_snapshot(base_snapshot_id)
238+
vm.ensure_running()
239+
if vm.status() != "running" or not _guest_ready(vm):
240+
raise RuntimeError("Parallels VM or guest tools did not become ready")
241+
242+
backend = RemoteDisplayBackend(
243+
owner_substr="Parallels Desktop", title_substr=WINDOW_TITLE
244+
)
245+
try:
246+
backend.ensure_foreground()
247+
except RemoteDisplayError as exc:
248+
raise RuntimeError(
249+
"remote-display client window is not foregroundable"
250+
) from exc
251+
252+
snap_id = vm.snapshot(
253+
f"oaflow-citrix-{int(time.time())}", description="citrix pixel e2e"
254+
)
255+
# A snapshot on a running VM briefly disturbs guest tools.
256+
if not _guest_ready(vm):
257+
raise RuntimeError("guest tools did not recover after pixel snapshot")
180258

181-
# SNAPSHOT FIRST — reverted in finally (never deleted).
182-
snap_id = vm.snapshot(
183-
f"oaflow-citrix-{int(time.time())}", description="citrix pixel e2e"
184-
)
185-
# A snapshot on a running VM briefly disturbs guest tools; wait for it back.
186-
_guest_ready(vm)
187-
try:
188259
# ---- deploy + launch the stand-in clinical app (pixel target) ------
189260
_deploy_and_launch(vm, drift="none")
190261
backend.ensure_foreground()
@@ -323,8 +394,18 @@ def driver(rec) -> None:
323394
assert not amb_report.success, (
324395
"run must HALT under render drift rather than click a guessed target"
325396
)
397+
except BaseException as exc:
398+
active_error = exc
399+
raise
326400
finally:
327401
try:
328-
vm.revert(snap_id) # never delete — revert only
329-
except Exception as e: # noqa: BLE001
330-
print(f"[citrix] WARNING: revert to {snap_id} failed: {e}")
402+
_restore_pixel_vm(
403+
vm,
404+
base_snapshot_id=base_snapshot_id,
405+
owned_snapshot_id=snap_id,
406+
vm_touched=vm_touched,
407+
)
408+
except Exception as cleanup_error:
409+
if active_error is None:
410+
raise
411+
active_error.add_note(f"cleanup error: {cleanup_error!r}")

tests/e2e/test_parallels_desktop_e2e.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,13 @@ def test_desktop_record_compile_replay_structural(tmp_path, trial: int) -> None:
416416
evidence_row["host_free_bytes_before"] = vm.require_host_free_space(
417417
storage_path=storage_path
418418
)
419+
# Refuse before the first mutation when this dedicated VM is not already
420+
# bound to the reviewed base. Reverting that same current id then proves
421+
# the live working state starts from the exact preserved snapshot.
422+
vm.require_current_snapshot(base_snapshot_id)
419423
vm_touched = True
424+
vm.revert(base_snapshot_id)
425+
vm.require_current_snapshot(base_snapshot_id)
420426
vm.ensure_running()
421427
# Snapshot before any guest deployment/recording. This id is retained
422428
# in memory and is the only snapshot the trial may later delete.

tests/test_ci_workflow_contract.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,20 @@ def test_validating_refresh_uses_exact_macos_parallels_substrate_and_scope() ->
151151
start = claims.index(" refresh-validating-evidence:")
152152
refresh = claims[start:]
153153

154-
assert "runs-on: [self-hosted, macos, arm64]" in refresh
154+
assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in claims
155+
assert "runs-on: [self-hosted, macos, arm64, openadapt-parallels]" in refresh
155156
assert "OPENADAPT_PARALLELS_VALIDATION_ENABLED" in refresh
157+
assert "group: openadapt-parallels-validation" in refresh
158+
assert "cancel-in-progress: false" in refresh
156159
assert "runs-on: ubuntu-latest" not in refresh
157160
assert "oa-vm" not in refresh
158161
assert "command -v prlctl" in refresh
159162
assert "OAFLOW_PARALLELS_BASE_SNAPSHOT_ID" in refresh
163+
assert "vm.require_current_snapshot" in refresh
160164
assert "--ci-job validating --junit runs/validating-junit.xml" in refresh
165+
assert refresh.index("tests/e2e/test_citrix_pixel_e2e.py") < refresh.index(
166+
"tests/e2e/test_parallels_desktop_e2e.py"
167+
)
161168
for path in (
162169
"tests/e2e/test_parallels_desktop_e2e.py",
163170
"tests/e2e/test_citrix_pixel_e2e.py",

0 commit comments

Comments
 (0)