diff --git a/.github/workflows/vm-qmp-controls.yml b/.github/workflows/vm-qmp-controls.yml
new file mode 100644
index 00000000..0a2ed034
--- /dev/null
+++ b/.github/workflows/vm-qmp-controls.yml
@@ -0,0 +1,46 @@
+name: Managed VM QMP component controls
+
+on:
+ pull_request:
+ paths:
+ - 'app/vm_qmp*.go'
+ - 'app/vm_display*.go'
+ - 'app/vm_process*.go'
+ - '.github/workflows/vm-qmp-controls.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ controls:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: app/go.mod
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ - name: Install native component dependency
+ run: sudo apt-get update -qq && sudo apt-get install -y -qq qemu-system-x86
+ - name: Build actual frontend for engine embed
+ working-directory: app/frontend
+ run: npm ci && npm run build
+ - name: Require native paused QEMU and source controls
+ env:
+ WOOTC_REQUIRE_NATIVE_QMP: '1'
+ run: |
+ set -o pipefail
+ mkdir -p "$RUNNER_TEMP/vm-qmp-controls"
+ go test -C app . -run 'Test(QMP|VMDisplay|ManagedVMShutdownContract)' \
+ -count=1 -race -v | tee "$RUNNER_TEMP/vm-qmp-controls/controls.log"
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: vm-qmp-component-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/vm-qmp-controls/controls.log
+ if-no-files-found: error
+ retention-days: 30
diff --git a/app/app.go b/app/app.go
index a691ce1f..43c84d4e 100644
--- a/app/app.go
+++ b/app/app.go
@@ -225,11 +225,19 @@ type App struct {
// mu guards status, cancellation, completion, and shutdown. GetStatus() is polled from the frontend
// on a timer while the install goroutine mutates status concurrently —
// without the lock that is a data race the Go race detector flags.
- mu sync.Mutex
- status InstallStatus
- cancel context.CancelFunc
- installDone chan struct{}
- stopping bool
+ mu sync.Mutex
+ status InstallStatus
+ cancel context.CancelFunc
+ installDone chan struct{}
+ stopping bool
+ vmMu sync.Mutex
+ vmSession *vmSession
+ vmCancel context.CancelFunc
+ vmPrepareDone chan struct{}
+ vmProbeMu sync.Mutex
+ vmProbeKey string
+ vmProbeResult vmExecutionProbeResult
+ vmProbeAt time.Time
}
func NewApp() *App {
@@ -276,6 +284,7 @@ func (a *App) startup(ctx context.Context) {
func previewMode() bool { return os.Getenv("WOOTC_UI_PREVIEW") == "1" }
func (a *App) shutdown(ctx context.Context) {
+ a.shutdownVM()
a.mu.Lock()
cancel := a.cancel
a.mu.Unlock()
@@ -719,6 +728,11 @@ type UninstallInfo struct {
// post-deploy loop (North Star audit): even when the deployer could not
// re-arm itself, the user has a button that actually starts their Linux.
func (a *App) BootIntoLinux() error {
+ release, err := acquireNativeInstallLease(wootcDir())
+ if err != nil {
+ return err
+ }
+ defer release()
if err := armOneShotFromPersistedGUID(); err != nil {
return err
}
@@ -921,6 +935,11 @@ func runPipeline(ctx context.Context, cfg InstallConfig, emit func(ProgressEvent
if err := prepareInstallState(cfg.StorageDrive); err != nil {
return err
}
+ release, err := acquireNativeInstallLease(wootcDir())
+ if err != nil {
+ return err
+ }
+ defer release()
// GUI routing is advisory. Enforce data preservation for every backend
// caller before changing power settings, files, or firmware boot entries.
if err := requireNewInstallRootDisk(); err != nil {
diff --git a/app/frontend/src/lib/e2e.js b/app/frontend/src/lib/e2e.js
index 8664ef2a..6bd96717 100644
--- a/app/frontend/src/lib/e2e.js
+++ b/app/frontend/src/lib/e2e.js
@@ -72,12 +72,57 @@ function driveInstall(directive, state) {
}
}
+async function drivePrepareVM(directive, state) {
+ if (window.__e2ePrepareVMRequested || state.screen !== 'launchpad') return;
+ await reportState(state, directive);
+
+ // Selecting a catalog card or applying a custom image re-renders the
+ // launchpad. Do that first, then look up fresh input elements so the
+ // remaining values are written into the controls the user can see.
+ if (directive.image) {
+ const currentRef = state.selected?.imageRef || '';
+ const card = [...document.querySelectorAll('.image-card')]
+ .find((candidate) => candidate.__imgRef === directive.image);
+ const imageInput = fieldByLabel('Custom supported OCI image');
+ if (card && currentRef !== directive.image) {
+ card.click();
+ } else if (!card && imageInput && currentRef !== directive.image) {
+ imageInput.value = directive.image;
+ if (imageInput.oninput) imageInput.oninput();
+ }
+ }
+
+ if (directive.image && state.selected?.imageRef === directive.image) {
+ await reportState(state, directive);
+ }
+
+ const username = fieldByLabel('Linux Username');
+ const passwords = document.querySelectorAll('input[type=password]');
+ if (!username || passwords.length < 2) return;
+
+ username.value = directive.username || '';
+ if (username.oninput) username.oninput();
+ for (const password of passwords) {
+ password.value = directive.password || '';
+ if (password.oninput) password.oninput();
+ }
+
+ if (directive.image && state.selected?.imageRef !== directive.image) return;
+ await reportState(state, directive);
+ const button = document.getElementById('vm-prepare-btn');
+ if (!button || button.disabled) return;
+
+ window.__e2ePrepareVMRequested = true;
+ button.click();
+ window.__e2ePrepareVMDriven = true;
+}
+
async function reportState(state, directive) {
// The harness reads imageMismatch to fail FAST when the directive's image
// cannot be selected (see the integrity gate in driveInstall) — the
// alternative is a silent install of the default image.
const wantRef = window.__e2eWantImage || '';
- await E2EDriveReport(JSON.stringify({
+ const report = {
schemaVersion: 1,
runId: directive.runId,
directiveId: directive.directiveId,
@@ -91,7 +136,21 @@ async function reportState(state, directive) {
selectedRef: state.selected?.imageRef || '',
imageMismatch: !!(wantRef && state.screen === 'launchpad' &&
!window.__e2eInstallDriven && state.selected?.imageRef !== wantRef),
- }));
+ };
+ if (directive.action === 'prepare-vm') Object.assign(report, {
+ vmPrepareDriven: !!window.__e2ePrepareVMDriven,
+ freshVmAvailable: !!state.freshVmCapability?.available,
+ freshVmReason: state.freshVmCapability?.reason || '',
+ vmPrepareButtonVisible: !!document.getElementById('vm-prepare-btn'),
+ vmPrepareButtonDisabled: document.getElementById('vm-prepare-btn')?.disabled ?? null,
+ vmRuntimeButtonVisible: [...document.querySelectorAll('button')]
+ .some((button) => button.textContent.includes('Set up Linux in a window')),
+ vmImageMismatch: !!(window.__e2eWantVMImage && state.screen === 'launchpad' &&
+ !window.__e2ePrepareVMDriven && state.selected?.imageRef !== window.__e2eWantVMImage),
+ vmReady: !!state.vmReady,
+ vmProgressStage: state.vmProgress?.stage || '',
+ });
+ await E2EDriveReport(JSON.stringify(report));
}
export function startE2EDrive(state) {
@@ -108,7 +167,7 @@ export function startE2EDrive(state) {
const directive = JSON.parse(raw);
if (directive.schemaVersion !== 1 || typeof directive.runId !== 'string' ||
!directive.runId || !/^[0-9a-f]{32}$/.test(directive.directiveId) ||
- !['install', 'reboot'].includes(directive.action)) throw new Error('Invalid drive identity');
+ !['install', 'reboot', 'prepare-vm'].includes(directive.action)) throw new Error('Invalid drive identity');
if (window.__e2eRunId && window.__e2eRunId !== directive.runId) throw new Error('Changed drive run');
window.__e2eRunId = directive.runId;
if (directive.action === 'install') {
@@ -121,6 +180,14 @@ export function startE2EDrive(state) {
window.__e2eWantImage = directive.image || '';
driveInstall(directive, state);
}
+ if (directive.action === 'prepare-vm') {
+ if (window.__e2ePrepareVMDirectiveId && window.__e2ePrepareVMDirectiveId !== directive.directiveId) {
+ throw new Error('Changed VM preparation directive');
+ }
+ window.__e2ePrepareVMDirectiveId = directive.directiveId;
+ window.__e2eWantVMImage = directive.image || '';
+ await drivePrepareVM(directive, state);
+ }
if (directive.action === 'reboot' && state.screen === 'done' && window.__e2eInstallDriven &&
window.__e2eInstallDirectiveId === directive.directiveId && !window.__e2eRebootDriven) {
window.__e2eRebootDriven = true;
diff --git a/app/frontend/src/main.js b/app/frontend/src/main.js
index 2b0531b6..2f88b568 100644
--- a/app/frontend/src/main.js
+++ b/app/frontend/src/main.js
@@ -1,5 +1,5 @@
import '../src/style.css';
-import { GetInstallSteps, GetImages, GetSystemInfo, ExistingInstallFound, GetMode, GetSessionCandidates, GetBranding, GetReleaseNotice, GetUninstallInfo, GetVMCapability, GetFreshVMCapability, GetSupportPolicy, GetLastRun, GetRecoveryVerdict, GetInstallRecovery } from '../wailsjs/go/main/App';
+import { GetInstallSteps, GetImages, GetSystemInfo, ExistingInstallFound, GetMode, GetSessionCandidates, GetBranding, GetReleaseNotice, GetUninstallInfo, GetVMCapability, GetVMState, GetFreshVMCapability, GetSupportPolicy, GetLastRun, GetRecoveryVerdict, GetInstallRecovery } from '../wailsjs/go/main/App';
import { EventsOn } from '../wailsjs/runtime/runtime';
import { startE2EDrive } from './lib/e2e.js';
import { state } from './lib/state.js';
@@ -43,11 +43,22 @@ async function init() {
// Try-in-VM builder progress (§6.1). Drives the preview screen while the
// headless builder pulls the image and installs it onto preview.raw.
- EventsOn('vm:progress', (e) => {
+ EventsOn('vm:progress', async (e) => {
state.vmProgress = { stage: e.stage, percent: e.percent || 0, message: e.message || '' };
- if (e.stage === 'started' || e.stage === 'ready') state.vmReady = true;
+ state.vmReady = e.stage === 'started';
+ if (['stopped', 'needs_recovery'].includes(e.stage)) state.vmState = { phase: e.stage, error: e.message };
+ if (e.stage === 'needs_recovery') state.vmError = e.message;
if (e.stage === 'error') state.vmError = e.message;
- if (state.screen === 'vmpreview') render();
+ if (e.stage === 'runtime-ready') {
+ try { state.freshVmCapability = await GetFreshVMCapability(); } catch (e) { state.vmError = String(e); }
+ if (state.freshVmCapability?.available) state.screen = 'launchpad';
+ else state.vmError = state.vmError || state.freshVmCapability?.reason || 'The Linux window check did not finish.';
+ render(); return;
+ }
+ if (['stopped', 'needs_recovery', 'started'].includes(e.stage)) {
+ try { state.vmState = await GetVMState(); state.vmCapability = await GetVMCapability(); } catch {}
+ }
+ if (state.screen === 'vmpreview' || state.screen === 'control') render();
});
// Conversion progress events from the migration dashboard backend.
@@ -128,6 +139,7 @@ async function init() {
state.config.diskSizeGB = Math.min(200, Math.max(40, half));
}
+ try { state.vmState = await GetVMState(); } catch { state.vmState = null; }
if (existing) {
try { state.uninstallInfo = await GetUninstallInfo(); } catch { state.uninstallInfo = {}; }
try { state.vmCapability = await GetVMCapability(); } catch { state.vmCapability = null; }
diff --git a/app/frontend/src/screens/control.js b/app/frontend/src/screens/control.js
index 6a09bd4f..354e7a3d 100644
--- a/app/frontend/src/screens/control.js
+++ b/app/frontend/src/screens/control.js
@@ -1,5 +1,5 @@
import { installStepLabel } from './progress.js';
-import { BootInVM, UninstallWith, BootIntoLinux } from '../../wailsjs/go/main/App';
+import { BootInVM, StopVM, ForceStopVM, GetVMState, GetVMCapability, UninstallWith, BootIntoLinux } from '../../wailsjs/go/main/App';
import { Quit } from '../../wailsjs/runtime/runtime';
import { state } from '../lib/state.js';
import { render } from '../lib/render.js';
@@ -102,6 +102,15 @@ export function renderControlPanel() {
// Boot-in-VM (§6.2): view Linux without rebooting, when the VM viewer is
// present and WHPX is on.
+ if (['running', 'stopping'].includes(state.vmState?.phase)) {
+ screen.appendChild(btn('Shut down Linux', 'btn btn-primary', async () => {
+ try { await StopVM(); state.vmState = await GetVMState(); state.vmCapability = await GetVMCapability(); render(); } catch (e) { alert(String(e)); }
+ }));
+ screen.appendChild(btn('Force stop…', 'btn btn-danger', async () => {
+ if (!confirm('Force stop Linux? Unsaved work may be lost and recovery will be required.')) return;
+ try { await ForceStopVM(); state.vmState = await GetVMState(); state.vmCapability = await GetVMCapability(); render(); } catch (e) { alert(String(e)); }
+ }));
+ }
const vm = state.vmCapability;
if (vm) {
const vmCard = el('div');
@@ -110,11 +119,11 @@ export function renderControlPanel() {
Try Linux in a window
${vm.available
- ? `Boot your installed ${distroName()} in a window using ${String(vm.accelerator || 'hardware acceleration').toUpperCase()}. Changes persist — it's the same system.`
+ ? `Boot your installed ${distroName()} in a window using ${String(vm.accelerator || 'hardware acceleration').split(',')[0].toUpperCase()}. Changes persist — it's the same system.`
: vm.reason}
`;
const vmBtn = btn('Boot in VM', 'btn btn-ghost', async () => {
- try { await BootInVM(); } catch (e) { alert('Could not start the VM: ' + e); }
+ try { await BootInVM(); state.vmState = await GetVMState(); state.vmCapability = await GetVMCapability(); render(); } catch (e) { alert('Could not start the VM: ' + e); }
});
vmBtn.style.flexShrink = '0';
vmBtn.disabled = !vm.available;
diff --git a/app/frontend/src/screens/launchpad.js b/app/frontend/src/screens/launchpad.js
index 088e4319..2d9a9d86 100644
--- a/app/frontend/src/screens/launchpad.js
+++ b/app/frontend/src/screens/launchpad.js
@@ -6,7 +6,7 @@ import { render } from '../lib/render.js';
import { installVerb, distroName, productName } from '../lib/branding.js';
import { el, btn, chip, warningBanner, inputField } from '../lib/ui.js';
import { renderProgress } from './progress.js';
-import { tryInVM } from './vmpreview.js';
+import { tryInVM, installVMRuntime } from './vmpreview.js';
const escapePhaseLabel = value => String(value).replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
@@ -355,13 +355,27 @@ export function renderLaunchpad() {
// Footer
const footer = el('div', 'footer');
- const installBtn = btn(`${installVerb()} →`, 'btn btn-primary', () => startInstall());
+ const installBtn = btn(`Advanced: ${installVerb()} with reboot`, 'btn btn-ghost', () => startInstall());
installBtn.id = 'install-btn';
footer.appendChild(btn('Cancel', 'btn btn-ghost', () => Quit()));
- // Try-in-VM (§6.1): gated on fresh VM capability (deferred to post-1.0 per ADR 0001 / #231;
- // available when builder artifacts are present in an offline bundle).
+ // VM-first is the normal path. Native installation remains an explicit action.
if (state.freshVmCapability?.available && state.selected) {
- footer.appendChild(btn('Try in VM', 'btn btn-ghost', () => tryInVM()));
+ const vmBtn = btn(`Start ${distroName()} in a window`, 'btn btn-primary', () => tryInVM());
+ vmBtn.id = 'vm-prepare-btn'; footer.appendChild(vmBtn);
+ const vmHint = el('div');
+ vmHint.id = 'vm-prepare-hint';
+ vmHint.setAttribute('role', 'status');
+ vmHint.setAttribute('aria-live', 'polite');
+ vmHint.style.cssText = 'font-size:12px;line-height:1.3;color:var(--text-dim);margin-right:auto;max-width:200px';
+ vmBtn.setAttribute('aria-describedby', vmHint.id);
+ footer.insertBefore(vmHint, footer.firstChild);
+ }
+ if (state.freshVmCapability?.runtimeNeeded) {
+ footer.appendChild(btn('Set up Linux in a window', 'btn btn-primary', () => installVMRuntime()));
+ }
+ if (!state.freshVmCapability?.available && state.freshVmCapability?.reason) {
+ const vmReason = el('div'); vmReason.style.cssText = 'font-size:12px;color:var(--text-muted)';
+ vmReason.textContent = state.freshVmCapability.reason; fields.appendChild(vmReason);
}
footer.appendChild(installBtn);
// Defer validity to after mount so the hint element exists.
@@ -399,6 +413,19 @@ function refreshInstallValidity() {
const hint = document.getElementById('install-hint');
if (!btn) return;
const c = state.config;
+ const vmBtn = document.getElementById('vm-prepare-btn');
+ if (vmBtn) {
+ let vmReason = '';
+ if (!state.selected) vmReason = 'Choose a Linux version above';
+ else if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(c.username) || c.username === 'root') vmReason = 'Enter a valid Linux username below';
+ else if (!c.password) vmReason = 'Set your Linux password below';
+ else if (c.password !== c.passwordConfirm) vmReason = 'Passwords do not match';
+ vmBtn.disabled = vmReason !== '';
+ vmBtn.textContent = `Start ${distroName()} in a window`;
+ vmBtn.title = vmReason;
+ const vmHint = document.getElementById('vm-prepare-hint');
+ if (vmHint) vmHint.textContent = vmReason;
+ }
let reason = '';
// Preflight safety gates (#63) come FIRST: these are conditions under which
// starting at all risks the user's data or leaves the machine half-converted.
@@ -444,6 +471,10 @@ function refreshPlanNote() {
const note = document.getElementById('plan-note');
if (!note) return;
const c = state.config;
+ if (state.freshVmCapability?.available) {
+ note.textContent = `Linux starts in a window with the account “${c.username || 'your username'}”. Your Windows files stay where they are. The disk, encryption, appearance and computer-name options under Advanced apply only to installation with a reboot.`;
+ return;
+ }
const disk = state.sysinfo?.bitLockerOn
? `${c.diskSizeGB} GB set aside for Linux`
: `${c.diskSizeGB} GB for Linux (space is only used as you fill it)`;
diff --git a/app/frontend/src/screens/vmpreview.js b/app/frontend/src/screens/vmpreview.js
index 5a4340f8..4b457e74 100644
--- a/app/frontend/src/screens/vmpreview.js
+++ b/app/frontend/src/screens/vmpreview.js
@@ -1,26 +1,41 @@
-import { TryInVMFresh } from '../../wailsjs/go/main/App';
+import { PrepareVM, InstallVMRuntime, StopVM, ForceStopVM, BootInVM, GetUninstallInfo, GetVMState, GetVMCapability } from '../../wailsjs/go/main/App';
import { state } from '../lib/state.js';
import { render } from '../lib/render.js';
import { distroName } from '../lib/branding.js';
import { el, btn } from '../lib/ui.js';
+const escapeText = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
// ── Try in VM (§6.1) ──────────────────────────────────────────────────────────
export async function tryInVM() {
if (!state.selected) return;
+ if (!state.config.password || state.config.password !== state.config.passwordConfirm) { alert('Set and confirm your Linux password first.'); return; }
state.screen = 'vmpreview';
state.vmProgress = { stage: 'pulling', percent: 0, message: 'Preparing the builder…' };
state.vmReady = false;
state.vmError = null;
+ state.vmState = null;
render();
try {
- await TryInVMFresh(state.selected.imageRef);
+ state.vmStartPromise = PrepareVM({ imageRef: state.selected.imageRef, username: state.config.username, password: state.config.password });
+ await state.vmStartPromise;
+ state.config.password = ''; state.config.passwordConfirm = '';
} catch (e) {
state.vmError = String(e);
render();
}
}
+export async function installVMRuntime() {
+ state.screen = 'vmpreview'; state.vmReady = false; state.vmError = null;
+ state.vmState = null;
+ state.vmProgress = { stage: 'runtime-manifest', percent: 0, message: 'Checking this release…' };
+ render();
+ try { state.vmStartPromise = InstallVMRuntime(); await state.vmStartPromise; }
+ catch (e) { state.vmError = String(e); render(); }
+}
+
export function renderVMPreviewScreen() {
const wrap = el('div');
wrap.style.cssText = 'display:flex;flex-direction:column;flex:1;overflow:hidden';
@@ -31,25 +46,45 @@ export function renderVMPreviewScreen() {
if (state.vmError) {
screen.innerHTML = `😕
Couldn't start the preview
- ${state.vmError}
`;
+ ${escapeText(state.vmError)}
`;
const back = btn('Back', 'btn btn-ghost', () => { state.screen = 'launchpad'; render(); });
screen.appendChild(back);
} else if (state.vmReady) {
screen.innerHTML = `🖥️
- Your VM window has opened
- ${state.selected?.name || distroName()} has started in its own window. Check that Linux reaches its desktop. Native boot is not available from this preview yet; keep the disk to preserve your work.
`;
+ Linux is starting
+ ${escapeText(state.selected?.name || distroName())} is starting in its own window. Sign in with your Linux username and password when the login screen appears. Your work stays on this disk. To close Linux safely, use Shut down Linux below. Native boot is not available yet.
`;
const row = el('div'); row.style.cssText = 'display:flex;gap:10px;margin-top:8px';
- row.appendChild(btn('Not now', 'btn btn-ghost', () => { state.screen = 'launchpad'; render(); }));
+ row.appendChild(btn('Shut down Linux', 'btn btn-primary', async () => {
+ try { await StopVM(); state.vmState = { phase: 'stopped' }; state.vmReady = false; render(); }
+ catch (e) { alert(String(e)); }
+ }));
+ row.appendChild(btn('Force stop…', 'btn btn-danger', async () => {
+ if (!confirm('Force stop Linux? Unsaved work may be lost. The disk will require recovery before restarting.')) return;
+ try { await ForceStopVM(); } catch (e) { alert(String(e)); }
+ }));
+ row.appendChild(btn('Manage Linux', 'btn btn-ghost', async () => {
+ state.uninstallInfo = await GetUninstallInfo(); state.vmState = await GetVMState(); state.vmCapability = await GetVMCapability();
+ state.screen = 'control'; render();
+ }));
screen.appendChild(row);
+ } else if (state.vmState?.phase === 'stopped') {
+ const title = el('h2'); title.textContent = 'Linux is shut down. Your work is saved on the same disk.'; screen.appendChild(title);
+ screen.appendChild(btn('Start Linux again', 'btn btn-primary', async () => {
+ try { await BootInVM(); state.vmReady = true; render(); } catch (e) { alert(String(e)); }
+ }));
} else {
const pct = Math.round(p.percent || 0);
screen.innerHTML = `🔨
- Building your preview…
- ${p.message || 'Working…'}
+ Preparing your Linux system…
+ ${escapeText(p.message || 'Working…')}
- ${pct}%
`;
+ Preparation is in progress.
`;
+ if (p.stage !== 'runtime-ready') screen.appendChild(btn('Cancel setup', 'btn btn-ghost', async () => {
+ try { await state.vmStartPromise; await ForceStopVM(); state.screen = 'launchpad'; render(); }
+ catch (e) { state.vmError = String(e); render(); }
+ }));
}
wrap.appendChild(screen);
return wrap;
diff --git a/app/frontend/wailsjs/go/main/App.js b/app/frontend/wailsjs/go/main/App.js
index 6ce3182a..8b8b1a4c 100644
--- a/app/frontend/wailsjs/go/main/App.js
+++ b/app/frontend/wailsjs/go/main/App.js
@@ -178,3 +178,10 @@ export function GetReleaseNotice() {
export function GetInstallSteps() {
return window['go']['main']['App']['GetInstallSteps']();
}
+export function GetVMState() { return window['go']['main']['App']['GetVMState'](); }
+export function StopVM() { return window['go']['main']['App']['StopVM'](); }
+export function ForceStopVM() { return window['go']['main']['App']['ForceStopVM'](); }
+
+export function PrepareVM(config) { return window['go']['main']['App']['PrepareVM'](config); }
+
+export function InstallVMRuntime() { return window['go']['main']['App']['InstallVMRuntime'](); }
diff --git a/app/installer_windows.go b/app/installer_windows.go
index c390a788..6359ede4 100644
--- a/app/installer_windows.go
+++ b/app/installer_windows.go
@@ -386,6 +386,22 @@ func uninstallWith(ctx context.Context, opts UninstallOptions) error {
}
setStorageDrive(drive)
+ targetDrives := []string{drive}
+ if drive != "C" {
+ targetDrives = append(targetDrives, "C")
+ }
+ for _, d := range targetDrives {
+ disk := filepath.Join(d+`:\wootc`, "disks", "root.disk")
+ release, err := acquireVMLock(disk)
+ if err != nil {
+ return err
+ }
+ defer release()
+ if err := verifyVMDiskReleased(disk); err != nil {
+ return err
+ }
+ }
+
var errs []string
// 0. Put back what install changed outside its folder: hibernation /
@@ -406,10 +422,6 @@ func uninstallWith(ctx context.Context, opts UninstallOptions) error {
// 3. Remove the install dir, staged files, cache, and logs.
// Clean both the active storage drive and C: if distinct.
- targetDrives := []string{drive}
- if drive != "C" {
- targetDrives = append(targetDrives, "C")
- }
for _, d := range targetDrives {
if opts.RemovePartition {
// Target files stay intact until the partition's final contents audit.
diff --git a/app/internal/runtimebundle/install.go b/app/internal/runtimebundle/install.go
new file mode 100644
index 00000000..900e006f
--- /dev/null
+++ b/app/internal/runtimebundle/install.go
@@ -0,0 +1,257 @@
+// Package runtimebundle installs a complete authenticated Windows VM runtime.
+package runtimebundle
+
+import (
+ "archive/zip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "wootc/internal/artifactauth"
+)
+
+const (
+ MaxArchiveBytes = 256 << 20
+ MaxExpandedBytes = 512 << 20
+ MaxFileBytes = 128 << 20
+ MaxFiles = 8192
+)
+
+// Install verifies the archive against a hash from an authenticated outer
+// release manifest, then verifies the inner manifest and every file. The caller
+// must hold the VM lease and supply an already protected, audited parent.
+// Publication is one rename; an existing parent/qemu is never updated in place.
+// A crash can leave a private .qemu-stage-* directory, never a partial qemu tree.
+func Install(ctx context.Context, archivePath, parent, expectedHash, publicKey string) error {
+ want, err := hex.DecodeString(expectedHash)
+ if err != nil || len(want) != sha256.Size {
+ return fmt.Errorf("invalid runtime archive hash")
+ }
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if !st.Mode().IsRegular() || st.Size() <= 0 || st.Size() > MaxArchiveBytes {
+ return fmt.Errorf("runtime archive exceeds bounds or is not a regular file")
+ }
+ hash := sha256.New()
+ n, err := io.Copy(hash, io.LimitReader(&contextReader{ctx, f}, MaxArchiveBytes+1))
+ if err != nil {
+ return err
+ }
+ if n != st.Size() {
+ return fmt.Errorf("runtime archive size changed")
+ }
+ if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), expectedHash) {
+ return fmt.Errorf("runtime archive checksum mismatch")
+ }
+ z, err := zip.NewReader(f, st.Size())
+ if err != nil {
+ return err
+ }
+ files, err := inspect(z)
+ if err != nil {
+ return err
+ }
+ manifest, err := readFile(files["SHA256SUMS"], artifactauth.MaxManifestSize)
+ if err != nil {
+ return err
+ }
+ signature, err := readFile(files["SHA256SUMS.sig"], 64)
+ if err != nil {
+ return err
+ }
+ sums, err := artifactauth.Verify(publicKey, manifest, signature)
+ if err != nil {
+ return err
+ }
+ if len(sums)+2 != len(files) {
+ return fmt.Errorf("runtime manifest and archive differ")
+ }
+ for name := range sums {
+ if name == "SHA256SUMS" || name == "SHA256SUMS.sig" || files[name] == nil {
+ return fmt.Errorf("runtime manifest file missing or reserved: %s", name)
+ }
+ }
+ for _, name := range []string{"qemu-system-x86_64.exe", "share/edk2-x86_64-code.fd", "share/edk2-i386-vars.fd", "builder-vmlinuz", "builder-initramfs.img", "builder-protocol.json"} {
+ if files[name] == nil {
+ return fmt.Errorf("required runtime file missing: %s", name)
+ }
+ }
+ // Metadata was authenticated above. Re-reading it during extraction must
+ // produce those exact bytes too, even if the source archive changes.
+ for name, data := range map[string][]byte{"SHA256SUMS": manifest, "SHA256SUMS.sig": signature} {
+ sum := sha256.Sum256(data)
+ sums[name] = hex.EncodeToString(sum[:])
+ }
+ if err = absent(filepath.Join(parent, "qemu")); err != nil {
+ return err
+ }
+ stage, err := os.MkdirTemp(parent, ".qemu-stage-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(stage)
+ for name, file := range files {
+ if err = ctx.Err(); err != nil {
+ return err
+ }
+ target := filepath.Join(stage, filepath.FromSlash(name))
+ if err = os.MkdirAll(filepath.Dir(target), 0700); err != nil {
+ return err
+ }
+ if err = extract(ctx, file, target, sums[name]); err != nil {
+ return err
+ }
+ }
+ if err = ctx.Err(); err != nil {
+ return err
+ }
+ if err = absent(filepath.Join(parent, "qemu")); err != nil {
+ return err
+ }
+ return os.Rename(stage, filepath.Join(parent, "qemu"))
+}
+
+func absent(path string) error {
+ _, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ return fmt.Errorf("runtime already exists; preserve it until a stopped-VM update: %s", path)
+}
+
+func inspect(z *zip.Reader) (map[string]*zip.File, error) {
+ if len(z.File) < 3 || len(z.File) > MaxFiles {
+ return nil, fmt.Errorf("runtime archive file count exceeds bounds")
+ }
+ files := map[string]*zip.File{}
+ spellings := map[string]string{}
+ var expanded uint64
+ for _, file := range z.File {
+ if !strings.HasPrefix(file.Name, "qemu/") || !file.Mode().IsRegular() {
+ return nil, fmt.Errorf("runtime entry must be a regular file under qemu: %s", file.Name)
+ }
+ name := strings.TrimPrefix(file.Name, "qemu/")
+ parts := strings.Split(name, "/")
+ for i, part := range parts {
+ if !validComponent(part) {
+ return nil, fmt.Errorf("unsafe Windows runtime path: %s", name)
+ }
+ prefix := strings.Join(parts[:i+1], "/")
+ folded := strings.ToLower(prefix)
+ if prior, ok := spellings[folded]; ok && prior != prefix {
+ return nil, fmt.Errorf("runtime path has conflicting case: %s", name)
+ }
+ spellings[folded] = prefix
+ }
+ if files[name] != nil {
+ return nil, fmt.Errorf("duplicate runtime entry: %s", name)
+ }
+ if file.UncompressedSize64 > MaxFileBytes {
+ return nil, fmt.Errorf("runtime file exceeds bounds: %s", name)
+ }
+ expanded += file.UncompressedSize64
+ if expanded > MaxExpandedBytes {
+ return nil, fmt.Errorf("expanded runtime exceeds bounds")
+ }
+ files[name] = file
+ }
+ for name := range files {
+ parts := strings.Split(name, "/")
+ for i := 1; i < len(parts); i++ {
+ if files[strings.Join(parts[:i], "/")] != nil {
+ return nil, fmt.Errorf("runtime file is also a directory: %s", name)
+ }
+ }
+ }
+ return files, nil
+}
+
+func validComponent(part string) bool {
+ if part == "" || part == "." || part == ".." || strings.TrimRight(part, ". ") != part {
+ return false
+ }
+ for _, char := range part {
+ if char <= 32 || char > 126 || strings.ContainsRune(`\:<>"|?*`, char) {
+ return false
+ }
+ }
+ stem := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
+ switch stem {
+ case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$":
+ return false
+ }
+ return !(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9')
+}
+
+func readFile(file *zip.File, limit int64) ([]byte, error) {
+ if file == nil || file.UncompressedSize64 > uint64(limit) {
+ return nil, fmt.Errorf("runtime metadata missing or exceeds bounds")
+ }
+ r, err := file.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ data, err := io.ReadAll(io.LimitReader(r, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > limit {
+ return nil, fmt.Errorf("runtime metadata exceeds bounds")
+ }
+ return data, nil
+}
+
+func extract(ctx context.Context, file *zip.File, target, checksum string) error {
+ r, err := file.Open()
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ w, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ defer w.Close()
+ hash := sha256.New()
+ n, err := io.Copy(io.MultiWriter(w, hash), io.LimitReader(&contextReader{ctx, r}, MaxFileBytes+1))
+ if err != nil {
+ return err
+ }
+ if n > MaxFileBytes || uint64(n) != file.UncompressedSize64 {
+ return fmt.Errorf("runtime file size mismatch: %s", file.Name)
+ }
+ if checksum != "" && hex.EncodeToString(hash.Sum(nil)) != checksum {
+ return fmt.Errorf("runtime file checksum mismatch: %s", file.Name)
+ }
+ if err = w.Sync(); err != nil {
+ return err
+ }
+ return w.Close()
+}
+
+type contextReader struct {
+ ctx context.Context
+ reader io.Reader
+}
+
+func (r *contextReader) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return r.reader.Read(p)
+}
diff --git a/app/internal/runtimebundle/install_test.go b/app/internal/runtimebundle/install_test.go
new file mode 100644
index 00000000..bfbf8216
--- /dev/null
+++ b/app/internal/runtimebundle/install_test.go
@@ -0,0 +1,206 @@
+package runtimebundle
+
+import (
+ "archive/zip"
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+ "wootc/internal/artifactauth"
+)
+
+type entry struct {
+ name string
+ data []byte
+ mode os.FileMode
+}
+
+func fixture(t *testing.T, mutate func(*[]entry, *string)) (string, string, string) {
+ t.Helper()
+ public, private, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var entries []entry
+ var manifest strings.Builder
+ for _, name := range []string{"qemu-system-x86_64.exe", "share/edk2-x86_64-code.fd", "share/edk2-i386-vars.fd", "builder-vmlinuz", "builder-initramfs.img", "builder-protocol.json", "dependency.dll"} {
+ data := []byte("test runtime " + name)
+ fmt.Fprintf(&manifest, "%x %s\n", sha256.Sum256(data), name)
+ entries = append(entries, entry{"qemu/" + name, data, 0600})
+ }
+ text := manifest.String()
+ if mutate != nil {
+ mutate(&entries, &text)
+ }
+ entries = append(entries, entry{"qemu/SHA256SUMS", []byte(text), 0600}, entry{"qemu/SHA256SUMS.sig", artifactauth.Sign(private, []byte(text)), 0600})
+ return makeZip(t, entries), hex.EncodeToString(public), text
+}
+
+func makeZip(t *testing.T, entries []entry) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "runtime.zip")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ z := zip.NewWriter(f)
+ for _, e := range entries {
+ h := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
+ h.SetMode(e.mode)
+ w, err := z.CreateHeader(h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = w.Write(e.data); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err = z.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err = f.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func archiveHash(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fmt.Sprintf("%x", sha256.Sum256(data))
+}
+
+func TestInstallAndPreserveExisting(t *testing.T) {
+ archive, key, _ := fixture(t, nil)
+ parent := t.TempDir()
+ if err := Install(context.Background(), archive, parent, archiveHash(t, archive), key); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(filepath.Join(parent, "qemu", "dependency.dll"))
+ if err != nil || string(got) != "test runtime dependency.dll" {
+ t.Fatalf("wrong installed file: %q %v", got, err)
+ }
+ if err = Install(context.Background(), archive, parent, archiveHash(t, archive), key); err == nil {
+ t.Fatal("overwrote existing runtime")
+ }
+ entries, err := os.ReadDir(parent)
+ if err != nil || len(entries) != 1 || entries[0].Name() != "qemu" {
+ t.Fatalf("left staging files: %v %v", entries, err)
+ }
+}
+
+func TestRejectArchiveAndPreserveParent(t *testing.T) {
+ cases := map[string]func(*[]entry, *string){
+ "extra DLL": func(e *[]entry, _ *string) { *e = append(*e, entry{"qemu/unsigned.dll", []byte("extra"), 0600}) },
+ "missing DLL": func(e *[]entry, _ *string) { *e = (*e)[:len(*e)-1] },
+ "edited payload": func(e *[]entry, _ *string) { (*e)[0].data = []byte("changed") },
+ "duplicate": func(e *[]entry, _ *string) { *e = append(*e, (*e)[0]) },
+ "directory case": func(e *[]entry, _ *string) { (*e)[2].name = "qemu/Share/edk2-i386-vars.fd" },
+ "file case": func(e *[]entry, _ *string) { *e = append(*e, entry{"qemu/DEPENDENCY.dll", []byte("extra"), 0600}) },
+ "traversal": func(e *[]entry, _ *string) { (*e)[0].name = "qemu/../escape" },
+ "drive": func(e *[]entry, _ *string) { (*e)[0].name = "qemu/C:/escape" },
+ "backslash": func(e *[]entry, _ *string) { (*e)[0].name = `qemu/share\escape` },
+ "alternate stream": func(e *[]entry, _ *string) { (*e)[0].name = "qemu/file:stream" },
+ "device": func(e *[]entry, _ *string) { (*e)[0].name = "qemu/NUL.dll" },
+ "trailing dot": func(e *[]entry, _ *string) { (*e)[0].name = "qemu/dependency.dll." },
+ "absolute": func(e *[]entry, _ *string) { (*e)[0].name = "/qemu/escape" },
+ "symlink": func(e *[]entry, _ *string) { (*e)[0].mode = os.ModeSymlink | 0777 },
+ "unmanifested required": func(e *[]entry, s *string) { *e = (*e)[1:]; *s = strings.Join(strings.Split(*s, "\n")[1:], "\n") },
+ }
+ names := make([]string, 0, len(cases))
+ for name := range cases {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ t.Run(name, func(t *testing.T) {
+ archive, key, _ := fixture(t, cases[name])
+ parent := t.TempDir()
+ if err := Install(context.Background(), archive, parent, archiveHash(t, archive), key); err == nil {
+ t.Fatal("accepted invalid bundle")
+ }
+ entries, err := os.ReadDir(parent)
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("published invalid or partial runtime: %v %v", entries, err)
+ }
+ })
+ }
+}
+
+func TestOuterHashSignatureAndCancellation(t *testing.T) {
+ archive, key, _ := fixture(t, nil)
+ for _, kind := range []string{"outer hash", "signature", "cancel"} {
+ t.Run(kind, func(t *testing.T) {
+ parent := t.TempDir()
+ hash := archiveHash(t, archive)
+ useKey := key
+ ctx := context.Background()
+ switch kind {
+ case "outer hash":
+ hash = strings.Repeat("0", 64)
+ case "signature":
+ useKey = strings.Repeat("0", 64)
+ case "cancel":
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ if err := Install(ctx, archive, parent, hash, useKey); err == nil {
+ t.Fatal("accepted untrusted or canceled installation")
+ }
+ entries, _ := os.ReadDir(parent)
+ if len(entries) != 0 {
+ t.Fatal("left partial runtime")
+ }
+ })
+ }
+}
+
+func TestRejectExpansionBounds(t *testing.T) {
+ for _, files := range [][]*zip.File{
+ make([]*zip.File, MaxFiles+1),
+ {{FileHeader: zip.FileHeader{Name: "qemu/large", UncompressedSize64: MaxFileBytes + 1}}, {FileHeader: zip.FileHeader{Name: "qemu/a"}}, {FileHeader: zip.FileHeader{Name: "qemu/b"}}},
+ {{FileHeader: zip.FileHeader{Name: "qemu/a", UncompressedSize64: MaxFileBytes}}, {FileHeader: zip.FileHeader{Name: "qemu/b", UncompressedSize64: MaxFileBytes}}, {FileHeader: zip.FileHeader{Name: "qemu/c", UncompressedSize64: MaxFileBytes}}, {FileHeader: zip.FileHeader{Name: "qemu/d", UncompressedSize64: MaxFileBytes}}, {FileHeader: zip.FileHeader{Name: "qemu/e", UncompressedSize64: 1}}},
+ } {
+ if _, err := inspect(&zip.Reader{File: files}); err == nil {
+ t.Fatal("accepted oversized ZIP metadata")
+ }
+ }
+}
+
+// An opt-in fixture test exercises the same installer on the actual signed
+// runtime archive, on Linux or native Windows. No production path reads these.
+func TestRealArchive(t *testing.T) {
+ archive := os.Getenv("WOOTC_TEST_RUNTIME_ARCHIVE")
+ if archive == "" {
+ t.Skip("no real runtime fixture supplied")
+ }
+ key := os.Getenv("WOOTC_TEST_RUNTIME_PUBLIC_KEY")
+ parent := t.TempDir()
+ if err := Install(context.Background(), archive, parent, archiveHash(t, archive), key); err != nil {
+ t.Fatal(err)
+ }
+ count := 0
+ if err := filepath.WalkDir(filepath.Join(parent, "qemu"), func(_ string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !entry.IsDir() {
+ count++
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("authenticated and installed %d runtime files", count)
+}
diff --git a/app/serve.go b/app/serve.go
index 1c1e6e87..51692f3a 100644
--- a/app/serve.go
+++ b/app/serve.go
@@ -51,6 +51,15 @@ var ProtocolMethods = []string{
"GetRecoveryVerdict",
"E2EDriveDirective",
"E2EDriveReport",
+ "GetVMCapability",
+ "GetFreshVMCapability",
+ "GetVMState",
+ "TryInVMFresh",
+ "PrepareVM",
+ "InstallVMRuntime",
+ "BootInVM",
+ "StopVM",
+ "ForceStopVM",
"Shutdown",
}
@@ -358,6 +367,50 @@ func (s *Server) dispatch(ctx context.Context, req jsonrpcRequest) (any, *jsonrp
s.app.E2EDriveReport(state)
return nil, nil
+ case "GetVMCapability":
+ return s.app.GetVMCapability(), nil
+ case "GetFreshVMCapability":
+ return s.app.GetFreshVMCapability(), nil
+ case "GetVMState":
+ return s.app.GetVMState(), nil
+ case "InstallVMRuntime":
+ if err := s.app.InstallVMRuntime(); err != nil {
+ return nil, &jsonrpcError{Code: errCodeInternal, Message: err.Error()}
+ }
+ return nil, nil
+ case "PrepareVM":
+ var cfg VMInstallConfig
+ if err := unmarshalParams(req.Params, &cfg); err != nil {
+ return nil, &jsonrpcError{Code: errCodeInvalidParams, Message: "invalid VM account parameters"}
+ }
+ if err := s.app.PrepareVM(cfg); err != nil {
+ return nil, &jsonrpcError{Code: errCodeInternal, Message: err.Error()}
+ }
+ return nil, nil
+ case "TryInVMFresh":
+ var image string
+ if err := unmarshalStringParam(req.Params, &image); err != nil {
+ return nil, &jsonrpcError{Code: errCodeInvalidParams, Message: err.Error()}
+ }
+ if err := s.app.TryInVMFresh(image); err != nil {
+ return nil, &jsonrpcError{Code: errCodeInternal, Message: err.Error()}
+ }
+ return nil, nil
+ case "BootInVM", "StopVM", "ForceStopVM":
+ var err error
+ switch req.Method {
+ case "BootInVM":
+ err = s.app.BootInVM()
+ case "StopVM":
+ err = s.app.StopVM()
+ case "ForceStopVM":
+ err = s.app.ForceStopVM()
+ }
+ if err != nil {
+ return nil, &jsonrpcError{Code: errCodeInternal, Message: err.Error()}
+ }
+ return nil, nil
+
case "Shutdown":
s.mu.Lock()
s.shutdown = true
@@ -395,6 +448,7 @@ func servePreparedRPC(ctx context.Context, app *App, in io.Reader, syncWriter *s
// Close the transport before waiting: a worker may be emitting progress
// to a shell that has already gone away.
syncWriter.stop()
+ app.shutdownVM()
app.stopInstall(30*time.Second, func() {
fmt.Fprintln(os.Stderr, "serve: installation is still stopping; waiting for disk/boot cleanup. Do not reboot.")
})
diff --git a/app/serve_test.go b/app/serve_test.go
index 97699f2b..52237ecd 100644
--- a/app/serve_test.go
+++ b/app/serve_test.go
@@ -176,10 +176,11 @@ func sampleDTOs() map[string]any {
"vm_capability": VMCapability{
Available: true,
Reason: "",
- DiskPath: `C:\wootc\preview.vhdx`,
- Accelerator: "hyperv",
- QEMUPath: `C:\Program Files\qemu\qemu-system-x86_64.exe`,
+ DiskPath: `C:\wootc\disks\root.disk`,
+ Accelerator: "whpx,kernel-irqchip=off",
+ QEMUPath: `C:\wootc\qemu\qemu-system-x86_64.exe`,
Bundled: true,
+ ProbeStatus: "passed",
},
"bundle_info": BundleInfo{
Image: "ghcr.io/tuna-os/yellowfin:gnome",
diff --git a/app/testdata/dto/vm_capability.json b/app/testdata/dto/vm_capability.json
index 0d680f86..d0861c6f 100644
--- a/app/testdata/dto/vm_capability.json
+++ b/app/testdata/dto/vm_capability.json
@@ -1,8 +1,10 @@
{
"available": true,
"reason": "",
- "diskPath": "C:\\wootc\\preview.vhdx",
- "accelerator": "hyperv",
- "qemuPath": "C:\\Program Files\\qemu\\qemu-system-x86_64.exe",
- "bundled": true
+ "diskPath": "C:\\wootc\\disks\\root.disk",
+ "accelerator": "whpx,kernel-irqchip=off",
+ "qemuPath": "C:\\wootc\\qemu\\qemu-system-x86_64.exe",
+ "bundled": true,
+ "probeStatus": "passed",
+ "runtimeNeeded": false
}
diff --git a/app/tools/gendto/main.go b/app/tools/gendto/main.go
index 58783c17..2bcf4b38 100644
--- a/app/tools/gendto/main.go
+++ b/app/tools/gendto/main.go
@@ -34,6 +34,8 @@ var DTOStructs = []string{
"SessionExport",
"VMEvent",
"VMCapability",
+ "VMState",
+ "VMInstallConfig",
"BundleInfo",
}
@@ -122,6 +124,8 @@ func goTypeToCSharp(f StructField) (string, string) {
return "int", ""
case "int64":
return "long", ""
+ case "uint64":
+ return "ulong", ""
case "float64":
return "double", ""
case "bool":
@@ -144,6 +148,8 @@ func mapBasicType(t string) string {
return "int"
case "int64":
return "long"
+ case "uint64":
+ return "ulong"
case "float64":
return "double"
case "bool":
diff --git a/app/tools/gendto/main_test.go b/app/tools/gendto/main_test.go
index 72d8e52c..2af4c1a0 100644
--- a/app/tools/gendto/main_test.go
+++ b/app/tools/gendto/main_test.go
@@ -57,6 +57,7 @@ func TestGoTypeToCSharp(t *testing.T) {
{"string omitempty", StructField{GoType: "string", Omitempty: true}, "string?", ""},
{"int", StructField{GoType: "int"}, "int", ""},
{"int64", StructField{GoType: "int64"}, "long", ""},
+ {"uint64", StructField{GoType: "uint64"}, "ulong", ""},
{"float64", StructField{GoType: "float64"}, "double", ""},
{"bool", StructField{GoType: "bool"}, "bool", ""},
{"named struct required", StructField{GoType: "SystemInfo"}, "SystemInfo", ""},
@@ -77,6 +78,7 @@ func TestMapBasicType(t *testing.T) {
{"string", "string"},
{"int", "int"},
{"int64", "long"},
+ {"uint64", "ulong"},
{"float64", "double"},
{"bool", "bool"},
{"Branding", "Branding"},
diff --git a/app/vm_account.go b/app/vm_account.go
new file mode 100644
index 00000000..7e7f669c
--- /dev/null
+++ b/app/vm_account.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// VMInstallConfig contains only the choices implemented by the VM preparation
+// path. Do not reuse native-install encryption/migration flags as implied promises.
+type VMInstallConfig struct {
+ ImageRef string `json:"imageRef"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type vmAccountInput struct {
+ SchemaVersion int `json:"schemaVersion"`
+ RunID string `json:"runId"`
+ InstallID string `json:"installId"`
+ Username string `json:"username"`
+ PasswordHash string `json:"passwordHash"`
+}
+
+var vmUsernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
+
+func validateVMInstallConfig(cfg VMInstallConfig) error {
+ if cfg.ImageRef == "" || strings.ContainsAny(cfg.ImageRef, " \t\r\n\x00") {
+ return fmt.Errorf("select a valid operating system image")
+ }
+ if !vmUsernamePattern.MatchString(cfg.Username) || cfg.Username == "root" {
+ return fmt.Errorf("choose a Linux username of up to 32 lowercase letters, digits, underscores or hyphens, beginning with a letter or underscore; root is reserved")
+ }
+ if cfg.Password == "" || len(cfg.Password) > 1024 || strings.ContainsAny(cfg.Password, "\x00\r\n") {
+ return fmt.Errorf("enter a password without line breaks, up to 1024 bytes")
+ }
+ return nil
+}
+
+// The caller holds the image lease and has verified the directory ACL. This
+// file is never an argument value containing credentials: QEMU receives only
+// its protected path, through fw_cfg. No secret enters durable lifecycle JSON.
+func writeVMAccountInput(root string, input vmAccountInput) (string, error) {
+ if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{7,63}$`).MatchString(input.RunID) {
+ return "", fmt.Errorf("invalid preparation run identity")
+ }
+ data, err := json.Marshal(input)
+ if err != nil {
+ return "", err
+ }
+ path := filepath.Join(root, ".run-account-"+input.RunID+".json")
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ return "", err
+ }
+ if _, err = file.Write(data); err == nil {
+ err = file.Sync()
+ }
+ closeErr := file.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ os.Remove(path)
+ return "", fmt.Errorf("could not stage private account input")
+ }
+ return path, nil
+}
+
+// Only call with the image lease: another live helper may still read its input.
+// A crashed run keeps its disk and diagnostic state, but not its credential file.
+func removeVMAccountInputs(root string) error {
+ matches, err := filepath.Glob(filepath.Join(root, ".run-account-*.json"))
+ if err != nil {
+ return err
+ }
+ for _, path := range matches {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("could not remove private account input")
+ }
+ }
+ return nil
+}
diff --git a/app/vm_account_test.go b/app/vm_account_test.go
new file mode 100644
index 00000000..ff952405
--- /dev/null
+++ b/app/vm_account_test.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestVMAccountInputIsPrivateTransientAndPreservesDisk(t *testing.T) {
+ root := t.TempDir()
+ disk := filepath.Join(root, "root.disk")
+ if err := os.WriteFile(disk, []byte("existing Linux files"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ input := vmAccountInput{SchemaVersion: 1, RunID: "run-12345", InstallID: "install-12345", Username: "alice", PasswordHash: "$6$test$hash"}
+ path, err := writeVMAccountInput(root, input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got vmAccountInput
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got != input {
+ t.Fatalf("private input changed: %+v", got)
+ }
+ if _, err := writeVMAccountInput(root, input); err == nil {
+ t.Fatal("existing account input overwritten")
+ }
+ if err := removeVMAccountInputs(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("stale account input survived cleanup")
+ }
+ data, err = os.ReadFile(disk)
+ if err != nil || string(data) != "existing Linux files" {
+ t.Fatal("credential cleanup damaged disk")
+ }
+}
+func TestVMAccountValidationDoesNotEchoPassword(t *testing.T) {
+ base := VMInstallConfig{ImageRef: "ghcr.io/test/os:latest", Username: "alice", Password: "private\nsecret"}
+ for _, which := range []string{"password", "root", "traversal", "image"} {
+ cfg := base
+ switch which {
+ case "root":
+ cfg.Username = "root"
+ case "traversal":
+ cfg.Username = "../alice"
+ case "image":
+ cfg.ImageRef = "image wootc.inject=yes"
+ }
+ err := validateVMInstallConfig(cfg)
+ if err == nil {
+ t.Errorf("accepted %s", which)
+ } else if strings.Contains(err.Error(), "private") {
+ t.Fatal("error leaked password")
+ }
+ }
+ base.Password = "a valid password"
+ if err := validateVMInstallConfig(base); err != nil {
+ t.Fatal(err)
+ }
+}
+func TestVMReceiptRequiresCreatedAccountIdentity(t *testing.T) {
+ path, id := vmTestDisk(t)
+ state := VMState{RunID: "run-12345", InstallID: "install-12345", Image: "image@digest", DiskPath: path, Username: "alice", AccountOutcome: "created"}
+ receipt := vmBuilderReceipt{Type: "result", SchemaVersion: 1, Status: "success", RunID: state.RunID, InstallID: state.InstallID, Image: state.Image, DiskID: id, FilesystemVerified: true, EFIVerified: true, AccountOutcome: "created", Username: "alice"}
+ check := func(receipt vmBuilderReceipt) error {
+ data, _ := json.Marshal(receipt)
+ _, err := verifyVMBuilderReceipt(strings.NewReader(string(data)+"\nSTATUS=SUCCESS\n"), state)
+ return err
+ }
+ if err := check(receipt); err != nil {
+ t.Fatal(err)
+ }
+ receipt.Username = "another"
+ if check(receipt) == nil {
+ t.Fatal("another account accepted")
+ }
+ receipt.Username = "alice"
+ receipt.AccountOutcome = "image-default"
+ if check(receipt) == nil {
+ t.Fatal("unpersonalized image accepted")
+ }
+}
diff --git a/app/vm_builder_result.go b/app/vm_builder_result.go
new file mode 100644
index 00000000..57ecb347
--- /dev/null
+++ b/app/vm_builder_result.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+)
+
+type vmBuilderReceipt struct {
+ Type string `json:"type"`
+ SchemaVersion int `json:"schemaVersion"`
+ Status string `json:"status"`
+ RunID string `json:"runId"`
+ InstallID string `json:"installId"`
+ Image string `json:"image"`
+ DiskID string `json:"diskId"`
+ FilesystemVerified bool `json:"filesystemVerified"`
+ EFIVerified bool `json:"efiVerified"`
+ AccountOutcome string `json:"accountOutcome"`
+ Username string `json:"username,omitempty"`
+}
+
+func verifyVMBuilderReceipt(input io.Reader, expected VMState) (vmBuilderReceipt, error) {
+ var receipt vmBuilderReceipt
+ limited := &io.LimitedReader{R: input, N: (64 << 20) + 1}
+ scanner := bufio.NewScanner(limited)
+ scanner.Buffer(make([]byte, 4096), 1<<20)
+ count := 0
+ success := false
+ failed := false
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "STATUS=SUCCESS" {
+ if count == 1 {
+ success = true
+ }
+ continue
+ }
+ var msg struct {
+ Type string `json:"type"`
+ Step string `json:"step"`
+ }
+ if json.Unmarshal([]byte(line), &msg) != nil {
+ continue
+ }
+ if msg.Step == "error" {
+ failed = true
+ }
+ if msg.Type == "result" {
+ count++
+ if err := json.Unmarshal([]byte(line), &receipt); err != nil {
+ return receipt, err
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return receipt, err
+ }
+ if limited.N == 0 {
+ return receipt, fmt.Errorf("builder log exceeds size limit")
+ }
+ if failed || count != 1 || !success {
+ return receipt, fmt.Errorf("builder did not supply one completed, successful installation receipt")
+ }
+ if receipt.SchemaVersion != 1 || receipt.Status != "success" || receipt.RunID != expected.RunID || receipt.InstallID != expected.InstallID || receipt.Image != expected.Image {
+ return receipt, fmt.Errorf("builder result does not match this installation/run/image")
+ }
+ accountOutcome := expected.AccountOutcome
+ if accountOutcome == "" {
+ accountOutcome = "image-default"
+ }
+ if !receipt.FilesystemVerified || !receipt.EFIVerified || receipt.AccountOutcome != accountOutcome || receipt.Username != expected.Username {
+ return receipt, fmt.Errorf("builder did not verify the filesystem, EFI boot, and account outcome")
+ }
+ diskID, err := vmDiskIdentity(expected.DiskPath)
+ if err != nil {
+ return receipt, err
+ }
+ if diskID != receipt.DiskID {
+ return receipt, fmt.Errorf("builder result names a different disk identity")
+ }
+ return receipt, nil
+}
diff --git a/app/vm_display.go b/app/vm_display.go
new file mode 100644
index 00000000..234f2644
--- /dev/null
+++ b/app/vm_display.go
@@ -0,0 +1,396 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// This internal surface never exports raw QMP or guest execution. Display
+// control does not prove Linux, session, editor, or desktop semantics.
+type vmDisplayRequest struct {
+ RunID, InstallID, DiskID, SessionID, DirectiveID string
+ Action string
+ Keys []vmDisplayKey
+}
+type vmDisplayKey struct {
+ Code string
+ Down bool
+}
+type vmDisplayObservation struct {
+ RunID, InstallID, DiskID, SessionID, DirectiveID string
+ Action string
+ Running bool
+ Mice []vmDisplayMouse
+ CapturePath, CaptureSHA256 string
+ Width, Height int
+ DesktopQualified bool
+}
+type vmDisplayMouse struct {
+ Name string `json:"name"`
+ Index int `json:"index"`
+ Current bool `json:"current"`
+ Absolute bool `json:"absolute"`
+}
+
+func freshVMSessionID() (string, error) {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+func (s *vmSession) acquireOperation(ctx context.Context) error {
+ select {
+ case s.operationGate <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+func (s *vmSession) releaseOperation() { <-s.operationGate }
+func (s *vmSession) displayIdentity(req vmDisplayRequest) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ select {
+ case <-s.done:
+ return fmt.Errorf("VM session exited")
+ default:
+ }
+ if s.state.Phase != vmRunning || s.sessionID == "" || req.SessionID != s.sessionID || req.RunID == "" || req.RunID != s.state.RunID || req.InstallID == "" || req.InstallID != s.state.InstallID || req.DiskID == "" || req.DiskID != s.state.DiskID {
+ return fmt.Errorf("display request does not match active VM identity")
+ }
+ return nil
+}
+func validDisplayDirective(id string) bool {
+ b, err := hex.DecodeString(id)
+ return err == nil && len(b) == 16 && hex.EncodeToString(b) == id
+}
+func strictQMPDecode(raw json.RawMessage, value any) error {
+ d := json.NewDecoder(strings.NewReader(string(raw)))
+ d.DisallowUnknownFields()
+ return d.Decode(value)
+}
+func (q *qmpClient) displayStatus(ctx context.Context) (bool, error) {
+ raw, err := q.request(ctx, "query-status", nil)
+ if err != nil {
+ return false, err
+ }
+ fields, shapeErr := decodeQMPObject(raw)
+ if shapeErr != nil || len(fields) != 3 || fields["running"] == nil || fields["singlestep"] == nil || fields["status"] == nil {
+ return false, fmt.Errorf("invalid status field shape")
+ }
+ var r struct {
+ Running *bool `json:"running"`
+ SingleStep *bool `json:"singlestep"`
+ Status string `json:"status"`
+ }
+ if err = strictQMPDecode(raw, &r); err != nil || r.Running == nil || r.SingleStep == nil || !*r.Running || r.Status != "running" {
+ return false, fmt.Errorf("VM is not positively observed running")
+ }
+ return true, nil
+}
+func (q *qmpClient) displayMice(ctx context.Context) ([]vmDisplayMouse, error) {
+ raw, err := q.request(ctx, "query-mice", nil)
+ if err != nil {
+ return nil, err
+ }
+ var rows []json.RawMessage
+ if err := json.Unmarshal(raw, &rows); err != nil {
+ return nil, err
+ }
+ for _, row := range rows {
+ fields, err := decodeQMPObject(row)
+ if err != nil || len(fields) != 4 || fields["name"] == nil || fields["index"] == nil || fields["current"] == nil || fields["absolute"] == nil {
+ return nil, fmt.Errorf("invalid mouse field shape")
+ }
+ }
+ var records []struct {
+ Name *string `json:"name"`
+ Index *int `json:"index"`
+ Current *bool `json:"current"`
+ Absolute *bool `json:"absolute"`
+ }
+ if err = strictQMPDecode(raw, &records); err != nil || len(records) == 0 || len(records) > 16 {
+ return nil, fmt.Errorf("invalid mouse inventory")
+ }
+ seen := map[int]bool{}
+ current := 0
+ var out []vmDisplayMouse
+ for _, r := range records {
+ if r.Name == nil || *r.Name == "" || r.Index == nil || *r.Index < 0 || *r.Index > 32 || seen[*r.Index] || r.Current == nil || r.Absolute == nil {
+ return nil, fmt.Errorf("invalid mouse")
+ }
+ seen[*r.Index] = true
+ if *r.Current {
+ current++
+ }
+ out = append(out, vmDisplayMouse{*r.Name, *r.Index, *r.Current, *r.Absolute})
+ }
+ if current != 1 {
+ return nil, fmt.Errorf("ambiguous active mouse")
+ }
+ return out, nil
+}
+func displayKeyEvents(keys []vmDisplayKey) ([]any, error) {
+ if len(keys) == 0 || len(keys) > 64 {
+ return nil, fmt.Errorf("invalid key batch size")
+ }
+ allowed := map[string]bool{}
+ for _, k := range strings.Fields("a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 spc ret tab esc backspace delete left right up down home end pgup pgdn shift ctrl alt dot comma minus equal slash") {
+ allowed[k] = true
+ }
+ pressed := map[string]bool{}
+ events := make([]any, 0, len(keys))
+ for _, key := range keys {
+ if !allowed[key.Code] || key.Down == pressed[key.Code] {
+ return nil, fmt.Errorf("invalid or unbalanced key event")
+ }
+ pressed[key.Code] = key.Down
+ events = append(events, map[string]any{"type": "key", "data": map[string]any{"down": key.Down, "key": map[string]string{"type": "qcode", "data": key.Code}}})
+ }
+ for _, down := range pressed {
+ if down {
+ return nil, fmt.Errorf("key batch leaves a key pressed")
+ }
+ }
+ return events, nil
+}
+func (s *vmSession) observeDisplay(ctx context.Context, req vmDisplayRequest) (vmDisplayObservation, error) {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ var out vmDisplayObservation
+ if !validDisplayDirective(req.DirectiveID) {
+ return out, fmt.Errorf("invalid display directive identity")
+ }
+ switch req.Action {
+ case "status", "mice", "keys", "capture":
+ default:
+ return out, fmt.Errorf("unsupported display action")
+ }
+ if req.Action != "keys" && len(req.Keys) != 0 {
+ return out, fmt.Errorf("unexpected keys")
+ }
+ var events []any
+ var err error
+ if req.Action == "keys" {
+ if events, err = displayKeyEvents(req.Keys); err != nil {
+ return out, err
+ }
+ }
+ if err = s.acquireOperation(ctx); err != nil {
+ return out, err
+ }
+ defer s.releaseOperation()
+ if err = s.displayIdentity(req); err != nil {
+ return out, err
+ }
+ if req.Action == "capture" {
+ if _, count, bytes, err := s.captureInventory(); err != nil {
+ return out, err
+ } else if count >= vmCaptureCountLimit || bytes >= vmCaptureBytesLimit {
+ return out, fmt.Errorf("retained capture budget exhausted")
+ }
+ }
+ if s.displayDirectives[req.DirectiveID] {
+ return out, fmt.Errorf("display directive replay")
+ }
+ if len(s.displayDirectives) >= 4096 {
+ return out, fmt.Errorf("display directive limit")
+ }
+ s.displayDirectives[req.DirectiveID] = true
+ out = vmDisplayObservation{RunID: req.RunID, InstallID: req.InstallID, DiskID: req.DiskID, SessionID: req.SessionID, DirectiveID: req.DirectiveID, Action: req.Action}
+ if out.Running, err = s.qmp.displayStatus(ctx); err != nil {
+ return vmDisplayObservation{}, err
+ }
+ switch req.Action {
+ case "mice":
+ out.Mice, err = s.qmp.displayMice(ctx)
+ case "keys":
+ var raw json.RawMessage
+ raw, err = s.qmp.request(ctx, "input-send-event", map[string]any{"events": events})
+ if err == nil {
+ var ack map[string]json.RawMessage
+ ack, err = decodeQMPObject(raw)
+ if err == nil && len(ack) != 0 {
+ err = fmt.Errorf("invalid key acknowledgment")
+ }
+ }
+ case "capture":
+ out.CapturePath, out.CaptureSHA256, out.Width, out.Height, err = s.captureDisplay(ctx)
+ }
+ if err != nil {
+ return vmDisplayObservation{}, err
+ }
+ if err = ctx.Err(); err != nil {
+ return vmDisplayObservation{}, err
+ }
+ if err = s.displayIdentity(req); err != nil {
+ return vmDisplayObservation{}, err
+ }
+ return out, nil
+}
+func (s *vmSession) captureDisplay(ctx context.Context) (string, string, int, int, error) {
+ root, count, retained, err := s.captureInventory()
+ if err != nil {
+ return "", "", 0, 0, err
+ }
+ if count >= vmCaptureCountLimit || retained >= vmCaptureBytesLimit {
+ return "", "", 0, 0, fmt.Errorf("retained capture budget exhausted")
+ }
+ dir, err := os.MkdirTemp(root, ".capture-"+s.sessionID+"-")
+ if err != nil {
+ return "", "", 0, 0, err
+ }
+ success := false
+ defer func() {
+ if !success {
+ _ = os.RemoveAll(dir)
+ }
+ }()
+ path := filepath.Join(dir, "frame.ppm")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ return "", "", 0, 0, err
+ }
+ if err = f.Close(); err != nil {
+ return "", "", 0, 0, err
+ }
+ raw, err := s.qmp.request(ctx, "screendump", map[string]string{"filename": path})
+ if err != nil {
+ return "", "", 0, 0, err
+ }
+ ack, err := decodeQMPObject(raw)
+ if err != nil || len(ack) != 0 {
+ return "", "", 0, 0, fmt.Errorf("invalid screendump acknowledgment")
+ }
+ info, err := os.Lstat(path)
+ if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > vmCaptureBytesLimit || retained+info.Size() > vmCaptureBytesLimit {
+ return "", "", 0, 0, fmt.Errorf("invalid capture file")
+ }
+ f, err = os.Open(path)
+ if err != nil {
+ return "", "", 0, 0, err
+ }
+ defer f.Close()
+ h := sha256.New()
+ r := bufio.NewReader(io.TeeReader(io.LimitReader(f, 64<<20), h))
+ magic, e1 := r.ReadString('\n')
+ dimensions, e2 := r.ReadString('\n')
+ depth, e3 := r.ReadString('\n')
+ parts := strings.Fields(dimensions)
+ if len(magic) > 16 || len(dimensions) > 64 || len(depth) > 16 || e1 != nil || e2 != nil || e3 != nil || magic != "P6\n" || depth != "255\n" || len(parts) != 2 {
+ return "", "", 0, 0, fmt.Errorf("invalid PPM header")
+ }
+ width, e1 := strconv.Atoi(parts[0])
+ height, e2 := strconv.Atoi(parts[1])
+ if e1 != nil || e2 != nil || width < 1 || height < 1 || width > 4096 || height > 4096 {
+ return "", "", 0, 0, fmt.Errorf("capture dimensions out of bounds")
+ }
+ n, err := io.Copy(io.Discard, r)
+ if err != nil || n != int64(width)*int64(height)*3 {
+ return "", "", 0, 0, fmt.Errorf("capture pixel count mismatch")
+ }
+ if err = ctx.Err(); err != nil {
+ return "", "", 0, 0, err
+ }
+ success = true
+ return path, hex.EncodeToString(h.Sum(nil)), width, height, nil
+}
+
+// The future test-only RPC route must call this engine-owned path. It receives
+// no pipe, socket, guest command, QMP command, or caller-selected capture path.
+func (a *App) observeVMDisplay(ctx context.Context, req vmDisplayRequest) (vmDisplayObservation, error) {
+ a.vmMu.Lock()
+ session := a.vmSession
+ a.vmMu.Unlock()
+ if session == nil {
+ return vmDisplayObservation{}, fmt.Errorf("no VM session owned by this engine")
+ }
+ out, err := session.observeDisplay(ctx, req)
+ if err != nil {
+ return vmDisplayObservation{}, err
+ }
+ a.vmMu.Lock()
+ same := a.vmSession == session
+ a.vmMu.Unlock()
+ if !same {
+ return vmDisplayObservation{}, fmt.Errorf("engine VM session changed")
+ }
+ return out, nil
+}
+
+const vmCaptureCountLimit = 8
+const vmCaptureBytesLimit int64 = 32 << 20
+
+func (s *vmSession) captureInventory() (string, int, int64, error) {
+ parent := filepath.Dir(s.statePath)
+ resolved, err := filepath.EvalSymlinks(parent)
+ absolute, absErr := filepath.Abs(parent)
+ if err != nil || absErr != nil || filepath.Clean(resolved) != filepath.Clean(absolute) {
+ return "", 0, 0, fmt.Errorf("capture parent must be canonical")
+ }
+ root := filepath.Join(parent, "captures")
+ if err = os.Mkdir(root, 0700); err != nil && !os.IsExist(err) {
+ return "", 0, 0, err
+ }
+ info, err := os.Lstat(root)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", 0, 0, fmt.Errorf("invalid capture namespace")
+ }
+ f, err := os.Open(root)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ defer f.Close()
+ names, err := f.Readdirnames(vmCaptureCountLimit + 1)
+ if err != nil && err != io.EOF {
+ return "", 0, 0, err
+ }
+ if len(names) > vmCaptureCountLimit {
+ return "", 0, 0, fmt.Errorf("retained capture count exceeded")
+ }
+ var retained int64
+ for _, name := range names {
+ parts := strings.Split(name, "-")
+ if len(parts) != 3 || parts[0] != ".capture" || !validDisplayDirective(parts[1]) || len(parts[2]) > 10 {
+ return "", 0, 0, fmt.Errorf("unknown capture directory")
+ }
+ if _, err = strconv.ParseUint(parts[2], 10, 32); err != nil {
+ return "", 0, 0, fmt.Errorf("unknown capture suffix")
+ }
+ dir := filepath.Join(root, name)
+ info, err = os.Lstat(dir)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", 0, 0, fmt.Errorf("invalid retained capture directory")
+ }
+ directory, openErr := os.Open(dir)
+ if openErr != nil {
+ return "", 0, 0, openErr
+ }
+ files, err := directory.Readdirnames(2)
+ directory.Close()
+ if (err != nil && err != io.EOF) || len(files) != 1 || files[0] != "frame.ppm" {
+ return "", 0, 0, fmt.Errorf("unknown retained capture files")
+ }
+ info, err = os.Lstat(filepath.Join(dir, "frame.ppm"))
+ if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > vmCaptureBytesLimit {
+ return "", 0, 0, fmt.Errorf("invalid retained capture file")
+ }
+ retained += info.Size()
+ if retained > vmCaptureBytesLimit {
+ return "", 0, 0, fmt.Errorf("retained capture bytes exceeded")
+ }
+ }
+ return root, len(names), retained, nil
+}
diff --git a/app/vm_display_test.go b/app/vm_display_test.go
new file mode 100644
index 00000000..b16b9f0e
--- /dev/null
+++ b/app/vm_display_test.go
@@ -0,0 +1,433 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Actual inherited-pipe child, not a VM. It exercises production QMP/capture
+// consumers but cannot establish guest or desktop acceptance.
+func TestVMDisplayChild(t *testing.T) {
+ mode := os.Getenv("WOOTC_DISPLAY_CHILD")
+ if mode == "" {
+ return
+ }
+ fmt.Println(`{"QMP":{"version":{},"capabilities":[]}}`)
+ r := bufio.NewScanner(os.Stdin)
+ for r.Scan() {
+ var req struct {
+ Execute string `json:"execute"`
+ ID string `json:"id"`
+ Args json.RawMessage `json:"arguments"`
+ }
+ if json.Unmarshal(r.Bytes(), &req) != nil {
+ os.Exit(9)
+ }
+ if path := os.Getenv("WOOTC_DISPLAY_SPY"); path != "" {
+ f, e := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if e != nil {
+ os.Exit(8)
+ }
+ fmt.Fprintln(f, req.Execute)
+ f.Close()
+ }
+ value := `{}`
+ switch req.Execute {
+ case "query-status":
+ if mode == "slow" {
+ time.Sleep(120 * time.Millisecond)
+ }
+ value = `{"running":true,"singlestep":false,"status":"running"}`
+ if mode == "paused" {
+ value = `{"running":false,"singlestep":false,"status":"paused"}`
+ }
+ if mode == "fake-status" {
+ value = `{"running":"true","singlestep":false,"status":"running"}`
+ }
+ case "query-mice":
+ value = `[{"name":"mouse","index":0,"current":true,"absolute":false}]`
+ if mode == "ambiguous-mice" {
+ value = `[{"name":"a","index":0,"current":true,"absolute":false},{"name":"b","index":1,"current":true,"absolute":true}]`
+ }
+ case "screendump":
+ var args struct {
+ Filename string `json:"filename"`
+ }
+ if json.Unmarshal(req.Args, &args) != nil {
+ os.Exit(7)
+ }
+ data := []byte("P6\n1 1\n255\nRGB")
+ if mode == "bad-pixels" {
+ data = []byte("P6\n1 1\n255\nR")
+ }
+ if mode != "missing-capture" {
+ if os.WriteFile(args.Filename, data, 0600) != nil {
+ os.Exit(6)
+ }
+ }
+ case "input-send-event":
+ var args struct {
+ Events []json.RawMessage `json:"events"`
+ }
+ if json.Unmarshal(req.Args, &args) != nil || len(args.Events) == 0 {
+ os.Exit(5)
+ }
+ if mode == "bad-key-ack" {
+ value = `{"delivered":true}`
+ }
+ }
+ fmt.Printf("{\"return\":%s,\"id\":%q}\n", value, req.ID)
+ if req.Execute == "system_powerdown" && mode != "hung-powerdown" {
+ fmt.Println(`{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}`)
+ os.Exit(0)
+ }
+ }
+ os.Exit(0)
+}
+func displaySession(t *testing.T, mode string) (*vmSession, vmDisplayRequest, string) {
+ t.Helper()
+ dir := t.TempDir()
+ spy := filepath.Join(dir, "spy")
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMDisplayChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_DISPLAY_CHILD="+mode, "WOOTC_DISPLAY_SPY="+spy)
+ state := VMState{RunID: "run", InstallID: "install", DiskID: "gpt", DiskPath: filepath.Join(dir, "root.disk"), Phase: vmReady}
+ s, err := startManagedVM(cmd, filepath.Join(dir, "state.json"), state, func() {}, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.force() })
+ req := vmDisplayRequest{RunID: "run", InstallID: "install", DiskID: "gpt", SessionID: s.sessionID, DirectiveID: strings.Repeat("a", 32), Action: "status"}
+ return s, req, spy
+}
+func TestVMDisplayActionsAndSessionNonce(t *testing.T) {
+ s, req, _ := displaySession(t, "normal")
+ other, _, _ := displaySession(t, "normal")
+ if s.sessionID == other.sessionID || !validDisplayDirective(s.sessionID) {
+ t.Fatal("session nonce was reused")
+ }
+ for i, action := range []string{"status", "mice", "keys", "capture"} {
+ req.Action = action
+ req.DirectiveID = fmt.Sprintf("%032x", i+1)
+ req.Keys = nil
+ if action == "keys" {
+ req.Keys = []vmDisplayKey{{"ctrl", true}, {"s", true}, {"s", false}, {"ctrl", false}}
+ }
+ out, err := s.observeDisplay(context.Background(), req)
+ if err != nil {
+ t.Fatal(action, err)
+ }
+ if !out.Running || out.DesktopQualified || out.SessionID != s.sessionID {
+ t.Fatal("QMP observation became desktop qualification or lost identity")
+ }
+ if action == "capture" {
+ if out.Width != 1 || out.Height != 1 || len(out.CaptureSHA256) != 64 {
+ t.Fatal("unverified capture")
+ }
+ if !strings.HasPrefix(out.CapturePath, filepath.Dir(s.statePath)+string(os.PathSeparator)) {
+ t.Fatal("capture escaped owned directory")
+ }
+ }
+ }
+ if s.snapshot().DesktopReady {
+ t.Fatal("desktopReady became true")
+ }
+}
+func TestVMDisplayIdentityAndReplayRefuseBeforeGuest(t *testing.T) {
+ s, req, spy := displaySession(t, "normal")
+ before, _ := os.ReadFile(spy)
+ for _, which := range []string{"run", "install", "disk", "session", "directive", "arbitrary-action"} {
+ bad := req
+ switch which {
+ case "run":
+ bad.RunID = "old"
+ case "install":
+ bad.InstallID = "old"
+ case "disk":
+ bad.DiskID = "old"
+ case "session":
+ bad.SessionID = "old"
+ case "directive":
+ bad.DirectiveID = "../escape"
+ case "arbitrary-action":
+ bad.Action = "human-monitor-command"
+ }
+ if _, err := s.observeDisplay(context.Background(), bad); err == nil {
+ t.Fatal(which, "accepted")
+ }
+ }
+ after, _ := os.ReadFile(spy)
+ if string(after) != string(before) {
+ t.Fatal("refused identity reached guest")
+ }
+ if _, err := s.observeDisplay(context.Background(), req); err != nil {
+ t.Fatal(err)
+ }
+ before, _ = os.ReadFile(spy)
+ if _, err := s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("directive replay accepted")
+ }
+ after, _ = os.ReadFile(spy)
+ if string(after) != string(before) {
+ t.Fatal("replay reached guest")
+ }
+}
+func TestVMDisplayUnknownOrFailedObservations(t *testing.T) {
+ for _, mode := range []string{"paused", "fake-status", "ambiguous-mice", "bad-pixels", "missing-capture", "bad-key-ack"} {
+ t.Run(mode, func(t *testing.T) {
+ s, req, _ := displaySession(t, mode)
+ switch mode {
+ case "ambiguous-mice":
+ req.Action = "mice"
+ case "bad-pixels", "missing-capture":
+ req.Action = "capture"
+ case "bad-key-ack":
+ req.Action = "keys"
+ req.Keys = []vmDisplayKey{{"a", true}, {"a", false}}
+ }
+ if out, err := s.observeDisplay(context.Background(), req); err == nil || out.DesktopQualified {
+ t.Fatal("failed observation accepted")
+ }
+ })
+ }
+}
+func TestVMDisplayKeyValidation(t *testing.T) {
+ for _, keys := range [][]vmDisplayKey{nil, {{"a", true}}, {{"a", false}}, {{"a", true}, {"a", true}}, {{"human-monitor-command", true}, {"human-monitor-command", false}}, make([]vmDisplayKey, 65)} {
+ if _, err := displayKeyEvents(keys); err == nil {
+ t.Fatal("invalid key batch accepted")
+ }
+ }
+}
+func TestVMDisplayDeadlineAndLifecycle(t *testing.T) {
+ s, req, _ := displaySession(t, "slow")
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ if _, err := s.observeDisplay(ctx, req); err == nil {
+ t.Fatal("blocking operation passed")
+ }
+ if time.Since(start) > 500*time.Millisecond {
+ t.Fatal("whole display deadline exceeded")
+ }
+ // Gate contention itself must honor the caller's deadline, without guest I/O.
+ s.operationGate <- struct{}{}
+ req.DirectiveID = strings.Repeat("b", 32)
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel2()
+ if _, err := s.observeDisplay(ctx2, req); err == nil {
+ t.Fatal("gate contention passed")
+ }
+ s.releaseOperation()
+ s.mu.Lock()
+ s.state.Phase = vmStopping
+ s.mu.Unlock()
+ if _, err := s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("stop/input race accepted")
+ }
+}
+func TestVMDisplaySymlinkCaptureParentRefused(t *testing.T) {
+ s, req, _ := displaySession(t, "normal")
+ dir := t.TempDir()
+ link := filepath.Join(dir, "link")
+ if err := os.Symlink(filepath.Dir(s.statePath), link); err != nil {
+ t.Skip(err)
+ }
+ s.statePath = filepath.Join(link, "state.json")
+ req.Action = "capture"
+ if _, err := s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("symlink parent accepted")
+ }
+}
+
+func TestVMDisplayEngineOwnerAndChangedSession(t *testing.T) {
+ s, req, _ := displaySession(t, "normal")
+ a := &App{}
+ if _, err := a.observeVMDisplay(context.Background(), req); err == nil {
+ t.Fatal("unowned session accepted")
+ }
+ a.vmSession = s
+ if out, err := a.observeVMDisplay(context.Background(), req); err != nil || out.DesktopQualified {
+ t.Fatal("engine route failed or qualified desktop", err)
+ }
+ slow, slowReq, spy := displaySession(t, "slow")
+ a.vmMu.Lock()
+ a.vmSession = slow
+ a.vmMu.Unlock()
+ result := make(chan error, 1)
+ go func() { _, err := a.observeVMDisplay(context.Background(), slowReq); result <- err }()
+ deadline := time.Now().Add(time.Second)
+ for {
+ data, _ := os.ReadFile(spy)
+ if strings.Contains(string(data), "query-status") {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("current status query never issued")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ a.vmMu.Lock()
+ a.vmSession = nil
+ a.vmMu.Unlock()
+ if err := <-result; err == nil {
+ t.Fatal("changed engine owner accepted stale session response")
+ }
+}
+
+func TestVMDisplayForceRemainsAvailableDuringShutdownWait(t *testing.T) {
+ s, req, spy := displaySession(t, "hung-powerdown")
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ stopped := make(chan error, 1)
+ go func() { stopped <- s.stop(ctx) }()
+ deadline := time.Now().Add(time.Second)
+ for {
+ data, _ := os.ReadFile(spy)
+ if strings.Contains(string(data), "system_powerdown") {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("shutdown request never observed")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ if _, err := s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("input accepted during shutdown")
+ }
+ start := time.Now()
+ if err := s.force(); err != nil {
+ t.Fatal(err)
+ }
+ if time.Since(start) > time.Second {
+ t.Fatal("force stop blocked behind guest shutdown wait")
+ }
+ if err := <-stopped; err == nil {
+ t.Fatal("forced stop became clean guest shutdown")
+ }
+ if s.snapshot().Phase != vmRecovery {
+ t.Fatal("forced stop lost recovery state")
+ }
+}
+
+func TestVMDisplayCaptureRetentionExhaustion(t *testing.T) {
+ s, req, spy := displaySession(t, "normal")
+ req.Action = "capture"
+ retained := map[string][]byte{}
+ for i := 0; i < vmCaptureCountLimit; i++ {
+ req.DirectiveID = fmt.Sprintf("%032x", 100+i)
+ out, err := s.observeDisplay(context.Background(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(out.CapturePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ retained[out.CapturePath] = data
+ }
+ root, count, bytes, err := s.captureInventory()
+ if err != nil || count != 8 || bytes > vmCaptureBytesLimit {
+ t.Fatal("retained files unbounded", root, count, bytes, err)
+ }
+ before, _ := os.ReadFile(spy)
+ req.DirectiveID = strings.Repeat("d", 32)
+ if _, err = s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("exhausted capture count accepted")
+ }
+ after, _ := os.ReadFile(spy)
+ if string(after) != string(before) {
+ t.Fatal("exhausted budget reached QMP")
+ }
+ for path, data := range retained {
+ got, err := os.ReadFile(path)
+ if err != nil || string(got) != string(data) {
+ t.Fatal("prior proof changed")
+ }
+ }
+ // A new launch nonce must not reset the retained byte/count inventory.
+ // The race-instrumented child spends one second in its exit hook.
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ if err = s.stop(ctx); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMDisplayChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_DISPLAY_CHILD=normal", "WOOTC_DISPLAY_SPY="+spy)
+ next, err := startManagedVM(cmd, s.statePath, s.snapshot(), func() {}, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer next.force()
+ if next.sessionID == s.sessionID {
+ t.Fatal("reused launch nonce")
+ }
+ req.SessionID = next.sessionID
+ before, _ = os.ReadFile(spy)
+ if _, err = next.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("restart reset capture budget")
+ }
+ after, _ = os.ReadFile(spy)
+ if string(after) != string(before) {
+ t.Fatal("restart exhaustion reached QMP")
+ }
+}
+func TestVMDisplayActualRetainedByteBudget(t *testing.T) {
+ s, req, spy := displaySession(t, "normal")
+ root, _, _, err := s.captureInventory()
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := filepath.Join(root, ".capture-"+s.sessionID+"-1")
+ if err = os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "frame.ppm")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = f.Truncate(vmCaptureBytesLimit); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+ _, count, bytes, err := s.captureInventory()
+ if err != nil || count != 1 || bytes != vmCaptureBytesLimit {
+ t.Fatal("actual file bytes not measured", count, bytes, err)
+ }
+ before, _ := os.ReadFile(spy)
+ req.Action = "capture"
+ if _, err = s.observeDisplay(context.Background(), req); err == nil {
+ t.Fatal("exhausted byte budget accepted")
+ }
+ after, _ := os.ReadFile(spy)
+ if string(before) != string(after) {
+ t.Fatal("byte exhaustion reached QMP")
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Size() != vmCaptureBytesLimit {
+ t.Fatal("prior evidence deleted or changed")
+ }
+}
+
+func TestVMDisplayNeverInheritsDesktopClaim(t *testing.T) {
+ root := t.TempDir()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMDisplayChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_DISPLAY_CHILD=normal")
+ state := VMState{RunID: "run", InstallID: "install", DiskID: "gpt", DiskPath: filepath.Join(root, "root.disk"), Phase: vmReady, DesktopReady: true}
+ s, err := startManagedVM(cmd, filepath.Join(root, "state.json"), state, func() {}, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.force()
+ if s.snapshot().DesktopReady {
+ t.Fatal("incoming state became live desktop proof without guest semantics")
+ }
+}
diff --git a/app/vm_environment_windows.go b/app/vm_environment_windows.go
new file mode 100644
index 00000000..7661c9ad
--- /dev/null
+++ b/app/vm_environment_windows.go
@@ -0,0 +1,20 @@
+//go:build windows
+
+package main
+
+import (
+ "golang.org/x/sys/windows"
+ "path/filepath"
+)
+
+// An elevated QEMU must not inherit user DLL/plugin/config search overrides.
+// Start with a whitelist; the system directory comes from the Windows API,
+// never from a caller-controlled SystemRoot or PATH environment variable.
+func vmProcessEnvironment(runtimeDir, tempDir string) []string {
+ system, err := windows.GetSystemDirectory()
+ if err != nil {
+ return []string{"PATH=" + runtimeDir, "TEMP=" + tempDir, "TMP=" + tempDir}
+ }
+ root := filepath.Dir(system)
+ return []string{"SystemRoot=" + root, "WINDIR=" + root, "PATH=" + runtimeDir + ";" + system, "TEMP=" + tempDir, "TMP=" + tempDir, "HOME=" + tempDir, "USERPROFILE=" + tempDir, "APPDATA=" + tempDir, "LOCALAPPDATA=" + tempDir}
+}
diff --git a/app/vm_guard.go b/app/vm_guard.go
new file mode 100644
index 00000000..63a9a93d
--- /dev/null
+++ b/app/vm_guard.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func vmStatePath(root string) string { return filepath.Join(root, "vm", "state.json") }
+
+// Native install must not turn VM-first into a fresh deployment of the same
+// disk. Promotion needs its separate verified transition; until then refuse.
+func acquireNativeInstallLease(root string) (func(), error) {
+ release, err := acquireVMLock(filepath.Join(root, "disks", "root.disk"))
+ if err != nil {
+ return nil, err
+ }
+ if _, err = os.Lstat(vmStatePath(root)); !os.IsNotExist(err) {
+ release()
+ if err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("this disk belongs to a managed VM; native promotion must preserve that system and is not available yet")
+ }
+ if err = verifyVMDiskReleased(filepath.Join(root, "disks", "root.disk")); err != nil {
+ release()
+ return nil, err
+ }
+ return release, nil
+}
diff --git a/app/vm_image_windows.go b/app/vm_image_windows.go
new file mode 100644
index 00000000..85db9c37
--- /dev/null
+++ b/app/vm_image_windows.go
@@ -0,0 +1,92 @@
+//go:build windows
+
+package main
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "golang.org/x/sys/windows"
+ "os"
+ "path/filepath"
+)
+
+func currentVMStoragePlan() (vmStoragePlan, vmStorageMinimums, error) {
+ plan := defaultVMStoragePlan()
+ metadata, err := readLocalMetadata(filepath.Join(qemuDir(), "builder-protocol.json"), 64<<10)
+ if err != nil {
+ return plan, vmStorageMinimums{}, err
+ }
+ minimums, err := parseVMStorageMinimums(metadata)
+ if err != nil {
+ return plan, minimums, err
+ }
+ _, err = plan.requiredFree(minimums)
+ return plan, minimums, err
+}
+
+func selectCurrentVMStorageProfile(image string) (vmStorageSelection, error) {
+ _, minimums, err := currentVMStoragePlan()
+ if err != nil {
+ return vmStorageSelection{}, err
+ }
+ manifest, err := readLocalMetadata(filepath.Join(qemuDir(), "SHA256SUMS"), 1<<20)
+ if err != nil {
+ return vmStorageSelection{}, err
+ }
+ identity := fmt.Sprintf("%x", sha256.Sum256(manifest))
+ return selectVMStorageProfile(vmCapacityExperiment, image, identity, minimums, approvedVMStorageProfiles())
+}
+
+func createVMImageFiles(selection vmStorageSelection) error {
+ // Selection uses the complete authenticated runtime while the image lease
+ // is held. Metadata cannot lower the image profile by itself.
+ plan, minimums := selection.Plan, selection.Minimums
+ available, err := vmFreeBytes(wootcDir())
+ if err != nil {
+ return err
+ }
+ if err := plan.admit(minimums, available); err != nil {
+ return err
+ }
+ for _, disk := range []struct {
+ path string
+ capacity uint64
+ }{
+ {managedVMRootDisk(), plan.Target},
+ {filepath.Join(previewDir(), "scratch.disk"), plan.Scratch},
+ } {
+ path, capacity := disk.path, disk.capacity
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600)
+ if err != nil {
+ return err
+ }
+ var returned uint32
+ // Sparse files are safe here: QEMU uses Windows file APIs. Native promotion
+ // must separately prove allocation/NTFS compatibility before mounting offline.
+ err = windows.DeviceIoControl(windows.Handle(file.Fd()), windows.FSCTL_SET_SPARSE, nil, 0, nil, 0, &returned, nil)
+ if err == nil {
+ err = file.Truncate(int64(capacity))
+ }
+ if err == nil {
+ err = file.Sync()
+ }
+ closeErr := file.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ }
+ return nil
+}
+
+func vmFreeBytes(root string) (uint64, error) {
+ pointer, err := windows.UTF16PtrFromString(root)
+ if err != nil {
+ return 0, err
+ }
+ var available, total, free uint64
+ err = windows.GetDiskFreeSpaceEx(pointer, &available, &total, &free)
+ return available, err
+}
diff --git a/app/vm_job_windows.go b/app/vm_job_windows.go
new file mode 100644
index 00000000..314dd4b7
--- /dev/null
+++ b/app/vm_job_windows.go
@@ -0,0 +1,78 @@
+//go:build windows
+
+package main
+
+import (
+ "fmt"
+ "golang.org/x/sys/windows"
+ "io"
+ "os/exec"
+ "syscall"
+ "unsafe"
+)
+
+type vmJob struct{ handle windows.Handle }
+
+func (j *vmJob) Close() error { return windows.CloseHandle(j.handle) }
+func ownVMProcess(cmd *exec.Cmd) (io.Closer, error) {
+ job, err := windows.CreateJobObject(nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
+ info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
+ windows.CloseHandle(job)
+ return nil, err
+ }
+ process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
+ if err != nil {
+ windows.CloseHandle(job)
+ return nil, err
+ }
+ defer windows.CloseHandle(process)
+ if err = windows.AssignProcessToJobObject(job, process); err != nil {
+ windows.CloseHandle(job)
+ return nil, err
+ }
+ if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED != 0 {
+ if err := resumeVMPrimaryThread(uint32(cmd.Process.Pid)); err != nil {
+ windows.CloseHandle(job)
+ return nil, err
+ }
+ }
+ return &vmJob{handle: job}, nil
+}
+
+// Suspend before the child can open a disk, assign ownership, then resume.
+// A crash before assignment may leave an inert process, never an unowned writer.
+func vmProcessAttributes() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_SUSPENDED}
+}
+func resumeVMPrimaryThread(pid uint32) error {
+ snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
+ if err != nil {
+ return err
+ }
+ defer windows.CloseHandle(snapshot)
+ entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
+ for err = windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) {
+ if entry.OwnerProcessID != pid {
+ continue
+ }
+ thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
+ if err != nil {
+ return err
+ }
+ previous, err := windows.ResumeThread(thread)
+ windows.CloseHandle(thread)
+ if err != nil {
+ return err
+ }
+ if previous != 1 {
+ return fmt.Errorf("unexpected VM thread suspension count: %d", previous)
+ }
+ return nil
+ }
+ return fmt.Errorf("could not find suspended VM primary thread: %w", err)
+}
diff --git a/app/vm_job_windows_test.go b/app/vm_job_windows_test.go
new file mode 100644
index 00000000..0d24503f
--- /dev/null
+++ b/app/vm_job_windows_test.go
@@ -0,0 +1,66 @@
+//go:build windows
+
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestVMOwnedJobReapsChildOnClose(t *testing.T) {
+ if os.Getenv("WOOTC_TEST_JOB_CHILD") == "1" {
+ fmt.Println("child-running")
+ time.Sleep(time.Minute)
+ os.Exit(0)
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMOwnedJobReapsChildOnClose$")
+ cmd.Env = append(os.Environ(), "WOOTC_TEST_JOB_CHILD=1")
+ cmd.SysProcAttr = vmProcessAttributes()
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = cmd.Process.Kill() })
+ job, err := ownVMProcess(cmd)
+ if err != nil {
+ cmd.Process.Kill()
+ cmd.Wait()
+ t.Fatal(err)
+ }
+ reader := bufio.NewReader(output)
+ if line, err := reader.ReadString('\n'); err != nil || strings.TrimSpace(line) != "child-running" {
+ job.Close()
+ cmd.Wait()
+ t.Fatalf("child never became active: %q %v", line, err)
+ }
+ if err := job.Close(); err != nil {
+ t.Fatal(err)
+ }
+ exited := make(chan error, 1)
+ go func() { exited <- cmd.Wait() }()
+ select {
+ case <-exited: // Windows may report exit code zero for job termination.
+ case <-time.After(5 * time.Second):
+ t.Fatal("owned child survived engine job closure")
+ }
+}
+
+func TestVMEnvironmentDoesNotInheritUserPluginPaths(t *testing.T) {
+ t.Setenv("GTK_PATH", `C:\Users\attacker`)
+ t.Setenv("QEMU_MODULE_DIR", `C:\Users\attacker`)
+ t.Setenv("PATH", `C:\Users\attacker`)
+ env := vmProcessEnvironment(`C:\wootc\qemu`, `C:\wootc\vm`)
+ for _, value := range env {
+ if strings.Contains(value, "attacker") {
+ t.Fatalf("inherited user search override: %s", value)
+ }
+ }
+}
diff --git a/app/vm_lifecycle.go b/app/vm_lifecycle.go
new file mode 100644
index 00000000..d9011fc2
--- /dev/null
+++ b/app/vm_lifecycle.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ vmPreparing = "preparing"
+ vmReady = "ready"
+ vmStarting = "starting"
+ vmRunning = "running"
+ vmStopping = "stopping"
+ vmStopped = "stopped"
+ vmRecovery = "needs_recovery"
+ vmFailed = "failed"
+)
+
+// VMState records the persistent installed system, not just a process. Ready
+// means provisioning verified the disk; DesktopReady needs separate guest proof.
+type VMState struct {
+ SchemaVersion int `json:"schemaVersion"`
+ InstallID string `json:"installId"`
+ RunID string `json:"runId"`
+ Image string `json:"image"`
+ Username string `json:"username,omitempty"`
+ AccountOutcome string `json:"accountOutcome,omitempty"`
+ DiskID string `json:"diskId"`
+ DiskPath string `json:"diskPath"`
+ StorageProfile string `json:"storageProfile,omitempty"`
+ TargetCapacityBytes uint64 `json:"targetCapacityBytes,omitempty"`
+ ScratchCapacityBytes uint64 `json:"scratchCapacityBytes,omitempty"`
+ RuntimeManifestSHA256 string `json:"runtimeManifestSHA256,omitempty"`
+ Phase string `json:"phase"`
+ DesktopReady bool `json:"desktopReady"`
+ PID int `json:"pid"`
+ Error string `json:"error,omitempty"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+func readVMState(path string) (VMState, error) {
+ var state VMState
+ f, err := os.Open(path)
+ if err != nil {
+ return state, err
+ }
+ defer f.Close()
+ if err = json.NewDecoder(io.LimitReader(f, 64*1024)).Decode(&state); err != nil {
+ return state, fmt.Errorf("read VM lifecycle: %w", err)
+ }
+ if state.SchemaVersion != 1 || state.InstallID == "" || state.DiskPath == "" {
+ return state, fmt.Errorf("invalid VM lifecycle identity")
+ }
+ return state, nil
+}
+
+func writeVMState(path string, state VMState) error {
+ state.SchemaVersion = 1
+ state.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+ // Never turn process or QMP liveness into a claim about a usable desktop.
+ state.DesktopReady = false
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return err
+ }
+ if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ temp, err := os.CreateTemp(filepath.Dir(path), ".vm-state-")
+ if err != nil {
+ return err
+ }
+ name := temp.Name()
+ defer os.Remove(name)
+ if _, err = temp.Write(data); err != nil {
+ temp.Close()
+ return err
+ }
+ if err = temp.Sync(); err != nil {
+ temp.Close()
+ return err
+ }
+ if err = temp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(name, path)
+}
+
+// The GPT disk GUID ties a helper's result and later VM launches to the same
+// image. A preallocated file, process PID, or matching file size is insufficient.
+func vmDiskIdentity(path string) (string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ header := make([]byte, 92)
+ if _, err = file.ReadAt(header, 512); err != nil {
+ return "", err
+ }
+ if string(header[:8]) != "EFI PART" {
+ return "", fmt.Errorf("VM disk has no GPT header")
+ }
+ guid := header[56:72]
+ if hex.EncodeToString(guid) == "00000000000000000000000000000000" {
+ return "", fmt.Errorf("VM disk has no identity")
+ }
+ return fmt.Sprintf("%08x-%04x-%04x-%x-%x", binary.LittleEndian.Uint32(guid), binary.LittleEndian.Uint16(guid[4:]), binary.LittleEndian.Uint16(guid[6:]), guid[8:10], guid[10:]), nil
+}
+
+func vmLaunchAllowed(state VMState, diskPath string) error {
+ if state.Phase != vmReady && state.Phase != vmStopped {
+ return fmt.Errorf("VM cannot start from %s; preserve its disk and resolve recovery first", state.Phase)
+ }
+ if filepath.Clean(state.DiskPath) != filepath.Clean(diskPath) {
+ return fmt.Errorf("VM disk path differs from its recorded installation")
+ }
+ actual, err := vmDiskIdentity(diskPath)
+ if err != nil {
+ return err
+ }
+ if actual != state.DiskID {
+ return fmt.Errorf("VM disk identity changed; refusing to boot another disk")
+ }
+ return nil
+}
diff --git a/app/vm_lifecycle_test.go b/app/vm_lifecycle_test.go
new file mode 100644
index 00000000..fc23c419
--- /dev/null
+++ b/app/vm_lifecycle_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func vmTestDisk(t *testing.T) (string, string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "root.disk")
+ data := make([]byte, 1024)
+ copy(data[512:], "EFI PART")
+ copy(data[568:], []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ id, err := vmDiskIdentity(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return path, id
+}
+func TestVMReceiptRejectsProxySuccess(t *testing.T) {
+ path, id := vmTestDisk(t)
+ state := VMState{RunID: "run-12345", InstallID: "install-123", Image: "repo@sha256:abc", DiskPath: path}
+ good := vmBuilderReceipt{Type: "result", SchemaVersion: 1, Status: "success", RunID: state.RunID, InstallID: state.InstallID, Image: state.Image, DiskID: id, FilesystemVerified: true, EFIVerified: true, AccountOutcome: "image-default"}
+ for _, which := range []string{"good", "only-marker", "wrong-run", "wrong-image", "wrong-disk", "no-efi", "duplicate", "late-error", "early-marker"} {
+ t.Run(which, func(t *testing.T) {
+ receipt := good
+ switch which {
+ case "wrong-run":
+ receipt.RunID = "another-run"
+ case "wrong-image":
+ receipt.Image = "another-image"
+ case "wrong-disk":
+ receipt.DiskID = "another-disk"
+ case "no-efi":
+ receipt.EFIVerified = false
+ }
+ data, _ := json.Marshal(receipt)
+ log := string(data) + "\nSTATUS=SUCCESS\n"
+ switch which {
+ case "only-marker":
+ log = "STATUS=SUCCESS\n"
+ case "duplicate":
+ log += string(data) + "\n"
+ case "late-error":
+ log += "{\"step\":\"error\"}\n"
+ case "early-marker":
+ log = "STATUS=SUCCESS\n" + string(data) + "\n"
+ }
+ _, err := verifyVMBuilderReceipt(strings.NewReader(log), state)
+ if (err == nil) != (which == "good") {
+ t.Fatalf("verification error = %v", err)
+ }
+ })
+ }
+}
+func TestVMDurableStateAndNativeWriterGuard(t *testing.T) {
+ root := t.TempDir()
+ path, id := vmTestDisk(t)
+ state := VMState{InstallID: "install-123", RunID: "run-12345", DiskPath: path, DiskID: id, Phase: vmStopped, DesktopReady: true}
+ if err := writeVMState(vmStatePath(root), state); err != nil {
+ t.Fatal(err)
+ }
+ got, err := readVMState(vmStatePath(root))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.DesktopReady {
+ t.Fatal("durable state claims desktop without guest evidence")
+ }
+ if err := vmLaunchAllowed(got, path); err != nil {
+ t.Fatal(err)
+ }
+ for _, phase := range []string{vmPreparing, vmRunning, vmStarting, vmStopping, vmRecovery, vmFailed} {
+ got.Phase = phase
+ if vmLaunchAllowed(got, path) == nil {
+ t.Errorf("launched %s", phase)
+ }
+ }
+ if release, err := acquireNativeInstallLease(root); err == nil {
+ release()
+ t.Fatal("native redeploy accepted managed image")
+ }
+ got.Phase = vmStopped
+ got.DiskID = "different"
+ if vmLaunchAllowed(got, path) == nil {
+ t.Fatal("changed disk accepted")
+ }
+}
diff --git a/app/vm_lock_other.go b/app/vm_lock_other.go
new file mode 100644
index 00000000..a6e99682
--- /dev/null
+++ b/app/vm_lock_other.go
@@ -0,0 +1,31 @@
+//go:build !windows
+
+package main
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "syscall"
+)
+
+func acquireVMLock(disk string) (func(), error) {
+ path, err := filepath.Abs(disk)
+ if err != nil {
+ return nil, err
+ }
+ hash := sha256.Sum256([]byte(path))
+ f, err := os.OpenFile(filepath.Join(os.TempDir(), fmt.Sprintf("wootc-image-%x.lock", hash)), os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, err
+ }
+ if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ f.Close()
+ return nil, fmt.Errorf("Linux disk is in use: %w", err)
+ }
+ var once sync.Once
+ return func() { once.Do(func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }) }, nil
+}
+func verifyVMDiskReleased(string) error { return nil }
diff --git a/app/vm_lock_windows.go b/app/vm_lock_windows.go
new file mode 100644
index 00000000..8b87ea42
--- /dev/null
+++ b/app/vm_lock_windows.go
@@ -0,0 +1,83 @@
+//go:build windows
+
+package main
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+// Mutex ownership is thread-bound on Windows. A dedicated pinned goroutine
+// retains it until release, including across a QEMU process lifetime. The
+// kernel abandons the mutex after an engine crash; durable VM state still
+// blocks an automatic restart of an image that may have been interrupted.
+func acquireVMLock(disk string) (func(), error) {
+ path, err := filepath.Abs(disk)
+ if err != nil {
+ return nil, err
+ }
+ hash := sha256.Sum256([]byte(strings.ToUpper(filepath.Clean(path))))
+ name, _ := windows.UTF16PtrFromString(fmt.Sprintf(`Global\wootc-image-%x`, hash))
+ ready := make(chan error, 1)
+ release := make(chan struct{})
+ done := make(chan struct{})
+ go func() {
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ defer close(done)
+ sd, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)")
+ if err != nil {
+ ready <- err
+ return
+ }
+ attr := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd}
+ handle, err := windows.CreateMutex(&attr, false, name)
+ if err != nil && err != windows.ERROR_ALREADY_EXISTS {
+ ready <- err
+ return
+ }
+ defer windows.CloseHandle(handle)
+ result, err := windows.WaitForSingleObject(handle, 0)
+ if err != nil {
+ ready <- err
+ return
+ }
+ if result != windows.WAIT_OBJECT_0 && result != windows.WAIT_ABANDONED {
+ ready <- fmt.Errorf("this Linux disk is in use by another operation or VM")
+ return
+ }
+ ready <- nil
+ <-release
+ _ = windows.ReleaseMutex(handle)
+ }()
+ if err := <-ready; err != nil {
+ <-done
+ return nil, err
+ }
+ var once sync.Once
+ return func() { once.Do(func() { close(release); <-done }) }, nil
+}
+
+// Called while holding the image lease. It also catches a QEMU process left
+// alive outside our manager: Windows must permit an exclusive image handle.
+func verifyVMDiskReleased(path string) error {
+ ptr, err := windows.UTF16PtrFromString(path)
+ if err != nil {
+ return err
+ }
+ h, err := windows.CreateFile(ptr, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
+ if err == windows.ERROR_FILE_NOT_FOUND || err == windows.ERROR_PATH_NOT_FOUND {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("Linux disk still has open handles; stop its VM first: %w", err)
+ }
+ return windows.CloseHandle(h)
+}
diff --git a/app/vm_managed_windows.go b/app/vm_managed_windows.go
new file mode 100644
index 00000000..37175fd4
--- /dev/null
+++ b/app/vm_managed_windows.go
@@ -0,0 +1,199 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+)
+
+func (a *App) GetVMState() VMState {
+ if previewMode() {
+ return VMState{SchemaVersion: 1, Phase: "unavailable", Error: "VM execution is unavailable in the UI test harness."}
+ }
+ a.vmMu.Lock()
+ defer a.vmMu.Unlock()
+ if a.vmSession != nil {
+ return a.vmSession.snapshot()
+ }
+ if a.vmCancel == nil {
+ if err := prepareTrustedStateTree(wootcDir()); err != nil {
+ return VMState{SchemaVersion: 1, Phase: vmRecovery, Error: err.Error()}
+ }
+ if release, err := acquireVMLock(managedVMRootDisk()); err == nil {
+ cleanupErr := removeVMAccountInputs(previewDir())
+ if cleanupErr == nil {
+ cleanupErr = cleanupVMRuntimeStaging(wootcDir())
+ }
+ release()
+ if cleanupErr != nil {
+ return VMState{SchemaVersion: 1, Phase: vmRecovery, Error: cleanupErr.Error()}
+ }
+ }
+ }
+ state, err := readVMState(vmStatePath(wootcDir()))
+ if os.IsNotExist(err) {
+ return VMState{SchemaVersion: 1, Phase: "absent"}
+ }
+ if err != nil {
+ return VMState{SchemaVersion: 1, Phase: vmRecovery, Error: err.Error()}
+ }
+ switch state.Phase {
+ case vmRunning, vmStarting, vmStopping, vmPreparing:
+ if a.vmCancel != nil {
+ return state
+ }
+ state.Phase = vmRecovery
+ state.Error = "The previous VM session did not record a clean stop. Its disk is preserved; check it before reuse."
+ }
+ state.DesktopReady = false
+ return state
+}
+
+func (a *App) StopVM() error {
+ a.vmMu.Lock()
+ session := a.vmSession
+ a.vmMu.Unlock()
+ if session == nil {
+ return fmt.Errorf("no VM is owned by this engine; inspect its recorded state before reuse")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+ return session.stop(ctx)
+}
+
+// ForceStopVM is a separate explicit action. It never changes the image back
+// to ready: an interrupted filesystem needs its own recovery check.
+func (a *App) ForceStopVM() error {
+ a.vmMu.Lock()
+ session := a.vmSession
+ cancel := a.vmCancel
+ done := a.vmPrepareDone
+ a.vmMu.Unlock()
+ if cancel != nil {
+ cancel()
+ if done != nil {
+ <-done
+ }
+ a.vmMu.Lock()
+ session = a.vmSession
+ a.vmMu.Unlock()
+ if session != nil {
+ return session.force()
+ }
+ return nil
+ }
+ if session == nil {
+ return fmt.Errorf("no active VM is owned by this engine")
+ }
+ return session.force()
+}
+
+func (a *App) shutdownVM() {
+ a.vmMu.Lock()
+ cancel := a.vmCancel
+ done := a.vmPrepareDone
+ a.vmMu.Unlock()
+ if cancel != nil {
+ cancel()
+ if done != nil {
+ <-done
+ }
+ }
+ a.vmMu.Lock()
+ session := a.vmSession
+ a.vmMu.Unlock()
+ if session != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ err := session.stop(ctx)
+ cancel()
+ if err != nil {
+ _ = session.force()
+ }
+ }
+}
+
+func managedVMRootDisk() string { return filepath.Join(wootcDir(), "disks", "root.disk") }
+func managedVMVars() string { return filepath.Join(wootcDir(), "vm", "firmware-vars.fd") }
+func edk2VarsTemplate() string { return filepath.Join(qemuDir(), "share", "edk2-i386-vars.fd") }
+
+func createVMVars() error {
+ if _, err := os.Stat(managedVMVars()); err == nil {
+ return nil
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ data, err := os.ReadFile(edk2VarsTemplate())
+ if err != nil {
+ return err
+ }
+ f, err := os.CreateTemp(filepath.Dir(managedVMVars()), ".firmware-vars-")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(f.Name())
+ if _, err = f.Write(data); err != nil {
+ f.Close()
+ return err
+ }
+ if err = f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ return os.Rename(f.Name(), managedVMVars())
+}
+
+// launchManagedDesktop consumes the already-acquired image lease. Every launch
+// reuses both the raw image and firmware variables; neither is recreated.
+func (a *App) launchManagedDesktop(cap VMCapability, state VMState, release func()) error {
+ if err := vmLaunchAllowed(state, managedVMRootDisk()); err != nil {
+ return err
+ }
+ if err := verifyVMDiskReleased(state.DiskPath); err != nil {
+ return err
+ }
+ if err := createVMVars(); err != nil {
+ return fmt.Errorf("prepare persistent VM firmware: %w", err)
+ }
+ memory, cpus, err := vmResourceBudget(getSystemInfo().RAMGB)
+ if err != nil {
+ return err
+ }
+ args := []string{"-accel", cap.Accelerator, "-machine", "q35", "-cpu", "max", "-m", fmt.Sprint(memory), "-smp", fmt.Sprint(cpus),
+ "-drive", vmDiskDrive(state.DiskPath),
+ "-drive", "if=pflash,format=raw,readonly=on,file=" + qemuEscape(edk2Code()),
+ "-drive", "if=pflash,format=raw,file=" + qemuEscape(managedVMVars()),
+ "-nic", "user", "-display", "gtk,window-close=off,show-menubar=off", "-qmp", "stdio", "-monitor", "none",
+ "-name", qemuEscape(effectiveBranding().Name + " (VM)")}
+ cmd := exec.Command(cap.QEMUPath, args...)
+ cmd.Dir = filepath.Dir(cap.QEMUPath)
+ cmd.Env = vmProcessEnvironment(cmd.Dir, previewDir())
+ cmd.SysProcAttr = vmProcessAttributes()
+ log, err := os.OpenFile(filepath.Join(wootcDir(), "vm", "qemu.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ return err
+ }
+ cmd.Stderr = log
+ session, err := startManagedVM(cmd, vmStatePath(wootcDir()), state, release, ownVMProcess)
+ if err != nil {
+ log.Close()
+ return err
+ }
+ a.vmMu.Lock()
+ a.vmSession = session
+ a.vmMu.Unlock()
+ go func() {
+ <-session.done
+ log.Close()
+ state := session.snapshot()
+ a.emitVM(VMEvent{Stage: state.Phase, Message: state.Error})
+ }()
+ return nil
+}
diff --git a/app/vm_other.go b/app/vm_other.go
index 43b45a88..2476730d 100644
--- a/app/vm_other.go
+++ b/app/vm_other.go
@@ -7,12 +7,14 @@ import "fmt"
// VMCapability mirrors the Windows type so the frontend bindings are stable
// across platforms.
type VMCapability struct {
- Available bool `json:"available"`
- Reason string `json:"reason"`
- DiskPath string `json:"diskPath"`
- Accelerator string `json:"accelerator"`
- QEMUPath string `json:"qemuPath"`
- Bundled bool `json:"bundled"`
+ Available bool `json:"available"`
+ Reason string `json:"reason"`
+ DiskPath string `json:"diskPath"`
+ Accelerator string `json:"accelerator"`
+ QEMUPath string `json:"qemuPath"`
+ Bundled bool `json:"bundled"`
+ ProbeStatus string `json:"probeStatus"`
+ RuntimeNeeded bool `json:"runtimeNeeded"`
}
func (a *App) GetVMCapability() VMCapability {
@@ -34,3 +36,18 @@ func (a *App) TryInVMFresh(imageRef string) error {
func (a *App) InstallPreviewForReal(cfg InstallConfig) error {
return fmt.Errorf("Try in VM is only available on Windows")
}
+
+func (a *App) GetVMState() VMState {
+ return VMState{SchemaVersion: 1, Phase: "unavailable", Error: "Managed VMs are only available on Windows."}
+}
+func (a *App) StopVM() error { return fmt.Errorf("managed VMs are only available on Windows") }
+func (a *App) ForceStopVM() error { return fmt.Errorf("managed VMs are only available on Windows") }
+func (a *App) shutdownVM() {}
+
+func (a *App) PrepareVM(cfg VMInstallConfig) error {
+ return fmt.Errorf("managed VMs are only available on Windows")
+}
+
+func (a *App) InstallVMRuntime() error {
+ return fmt.Errorf("the VM runtime is only available on Windows")
+}
diff --git a/app/vm_probe.go b/app/vm_probe.go
new file mode 100644
index 00000000..f0d23109
--- /dev/null
+++ b/app/vm_probe.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+//go:embed vmprobe/serial-boot.img
+var vmProbeBootSector []byte
+
+const vmProbeMarker = "WOOTC_WHPX_BOOTSECTOR_EXECUTED"
+
+// newVMProbeCommand keeps the guest runtime in its authenticated bundle but
+// runs QEMU from disposable state. Windows may write compatibility caches
+// relative to the current directory; those files must not land in qemuDir().
+func newVMProbeCommand(qemuPath, workingDir string, args ...string) *exec.Cmd {
+ cmd := exec.Command(qemuPath, args...)
+ cmd.Dir = workingDir
+ return cmd
+}
+
+type vmExecutionProbeResult struct {
+ Status string
+ Detail string
+ Accelerator string
+}
+
+// Read guest serial from a newly created file, never process liveness or an
+// old diagnostic log. The image is a read-only 512-byte program with no OS or
+// user data. Terminating this probe cannot interrupt a writable filesystem.
+func runVMExecutionProbe(ctx context.Context, cmd *exec.Cmd, serialPath string, own func(*exec.Cmd) (io.Closer, error)) vmExecutionProbeResult {
+ result := vmExecutionProbeResult{Status: "failed"}
+ serial, err := os.OpenFile(serialPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ result.Detail = "Could not create a private VM execution probe."
+ return result
+ }
+ serial.Close()
+ if err = cmd.Start(); err != nil {
+ result.Detail = fmt.Sprintf("Could not start the VM execution probe: %v", err)
+ return result
+ }
+ job, err := own(cmd)
+ if err != nil {
+ cmd.Process.Kill()
+ cmd.Wait()
+ result.Detail = "Could not own the VM execution probe."
+ return result
+ }
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+ defer func() { _ = cmd.Process.Kill(); _ = job.Close(); <-done }()
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ result.Status = "timed_out"
+ result.Detail = "The guest execution check did not finish within its time limit. A slow or busy PC can cause this; it does not prove this PC is unsupported. Close other apps and try again."
+ return result
+ case err := <-done:
+ done <- err // leave the single completion for the cleanup barrier
+ result.Detail = "The VM execution probe exited before guest code reported success."
+ return result
+ case <-ticker.C:
+ data, err := readLocalMetadata(serialPath, 8192)
+ if err == nil && strings.Contains(string(data), vmProbeMarker) {
+ result.Status = "passed"
+ return result
+ }
+ }
+ }
+}
diff --git a/app/vm_probe_test.go b/app/vm_probe_test.go
new file mode 100644
index 00000000..b926ebb4
--- /dev/null
+++ b/app/vm_probe_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestVMExecutionProbeChild(t *testing.T) {
+ mode := os.Getenv("WOOTC_TEST_PROBE_CHILD")
+ if mode == "" {
+ return
+ }
+ switch mode {
+ case "guest":
+ _ = os.WriteFile(os.Getenv("WOOTC_TEST_PROBE_SERIAL"), []byte(vmProbeMarker), 0600)
+ case "stdout":
+ fmt.Println(vmProbeMarker)
+ case "exit":
+ os.Exit(0)
+ }
+ time.Sleep(time.Minute)
+ os.Exit(0)
+}
+func TestVMProbeCommandRunsOutsideTheAuthenticatedRuntime(t *testing.T) {
+ root := t.TempDir()
+ runtimeDir := filepath.Join(root, "qemu")
+ privateDir := filepath.Join(root, "preview", ".probe-run")
+ cmd := newVMProbeCommand(filepath.Join(runtimeDir, "qemu-system-x86_64.exe"), privateDir, "-version")
+ if filepath.Clean(cmd.Dir) != filepath.Clean(privateDir) {
+ t.Fatalf("probe working directory = %q, want disposable directory %q", cmd.Dir, privateDir)
+ }
+ if filepath.Clean(cmd.Dir) == filepath.Clean(runtimeDir) {
+ t.Fatal("probe may write host-generated files into the authenticated runtime")
+ }
+}
+
+func TestVMExecutionProbeNeedsActualSerial(t *testing.T) {
+ for _, mode := range []string{"guest", "stdout", "hung", "exit"} {
+ t.Run(mode, func(t *testing.T) {
+ serial := filepath.Join(t.TempDir(), "serial.log")
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMExecutionProbeChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_TEST_PROBE_CHILD="+mode, "WOOTC_TEST_PROBE_SERIAL="+serial)
+ var output bytes.Buffer
+ cmd.Stdout = &output
+ timeout := time.Second
+ if mode == "guest" {
+ timeout = 5 * time.Second
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ result := runVMExecutionProbe(ctx, cmd, serial, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if mode == "stdout" && !strings.Contains(output.String(), vmProbeMarker) {
+ t.Fatal("stdout-only fixture did not execute")
+ }
+ if (result.Status == "passed") != (mode == "guest") {
+ t.Fatalf("mode %s: %+v", mode, result)
+ }
+ if (mode == "hung" || mode == "stdout") && result.Status != "timed_out" {
+ t.Fatalf("silence mislabeled unsupported: %+v", result)
+ }
+ })
+ }
+}
+func TestVMExecutionProbeRefusesStaleLog(t *testing.T) {
+ serial := filepath.Join(t.TempDir(), "serial.log")
+ os.WriteFile(serial, []byte(vmProbeMarker), 0600)
+ result := runVMExecutionProbe(context.Background(), exec.Command("does-not-exist"), serial, func(*exec.Cmd) (io.Closer, error) { t.Fatal("started with stale log"); return nil, nil })
+ if result.Status == "passed" {
+ t.Fatal("stale guest result accepted")
+ }
+}
+func TestVMProbeBootSectorReproducible(t *testing.T) {
+ if len(vmProbeBootSector) != 512 || fmt.Sprintf("%x", sha256.Sum256(vmProbeBootSector)) != "f591bb62b9fce510dc7a4a096940ffbfc596d925739416559017cfb2a92e7195" {
+ t.Fatal("probe binary changed; rebuild and verify source")
+ }
+}
diff --git a/app/vm_probe_windows.go b/app/vm_probe_windows.go
new file mode 100644
index 00000000..14798177
--- /dev/null
+++ b/app/vm_probe_windows.go
@@ -0,0 +1,70 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+func (a *App) probeVMRuntime(cap VMCapability) VMCapability {
+ a.vmProbeMu.Lock()
+ defer a.vmProbeMu.Unlock()
+ manifest, err := os.ReadFile(filepath.Join(qemuDir(), "SHA256SUMS"))
+ if err != nil {
+ cap.Available = false
+ cap.Reason = "The runtime manifest disappeared before the execution check."
+ return cap
+ }
+ key := fmt.Sprintf("%s:%x", cap.QEMUPath, sha256.Sum256(manifest))
+ result := a.vmProbeResult
+ if a.vmProbeKey != key || time.Since(a.vmProbeAt) > time.Minute {
+ result = a.executeVMProbe(cap)
+ a.vmProbeKey = key
+ a.vmProbeResult = result
+ a.vmProbeAt = time.Now()
+ }
+ cap.ProbeStatus = result.Status
+ cap.Available = result.Status == "passed"
+ cap.Reason = result.Detail
+ if cap.Available {
+ cap.Accelerator = result.Accelerator
+ }
+ return cap
+}
+
+func (a *App) executeVMProbe(cap VMCapability) vmExecutionProbeResult {
+ failed := vmExecutionProbeResult{Status: "failed", Detail: "Could not prepare the private VM execution check."}
+ if err := os.MkdirAll(previewDir(), 0700); err != nil {
+ return failed
+ }
+ dir, err := os.MkdirTemp(previewDir(), ".probe-")
+ if err != nil {
+ return failed
+ }
+ defer os.RemoveAll(dir)
+ disk := filepath.Join(dir, "probe.raw")
+ if err := os.WriteFile(disk, vmProbeBootSector, 0600); err != nil {
+ return failed
+ }
+ serial := filepath.Join(dir, "serial.log")
+ accelerator := "whpx,kernel-irqchip=off"
+ cmd := newVMProbeCommand(cap.QEMUPath, dir, "-machine", "q35", "-accel", accelerator, "-cpu", "max", "-smp", "1", "-m", "128", "-display", "none", "-monitor", "none", "-serial", "file:"+serial, "-drive", "file="+qemuEscape(disk)+",format=raw,if=floppy,readonly=on", "-boot", "a")
+ cmd.Env = vmProcessEnvironment(qemuDir(), dir)
+ cmd.SysProcAttr = vmProcessAttributes()
+ stderr, err := os.OpenFile(filepath.Join(dir, "stderr.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ return failed
+ }
+ defer stderr.Close()
+ cmd.Stderr = stderr
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ result := runVMExecutionProbe(ctx, cmd, serial, ownVMProcess)
+ result.Accelerator = accelerator
+ return result
+}
diff --git a/app/vm_probe_windows_test.go b/app/vm_probe_windows_test.go
new file mode 100644
index 00000000..897cf00b
--- /dev/null
+++ b/app/vm_probe_windows_test.go
@@ -0,0 +1,42 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// Explicit fixture-only entry point. Production has no environment-selected
+// runtime, public key, or TCG fallback. Corral supplies its isolated signed tree.
+func TestVMExecutionProbeRealQEMU(t *testing.T) {
+ runtimeDir := os.Getenv("WOOTC_TEST_QEMU_RUNTIME")
+ if runtimeDir == "" {
+ t.Skip("requires isolated signed QEMU fixture")
+ }
+ if err := verifyVMRuntime(runtimeDir, os.Getenv("WOOTC_TEST_ARTIFACT_PUBLIC_KEY")); err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ disk := filepath.Join(dir, "probe.raw")
+ serial := filepath.Join(dir, "serial.log")
+ if err := os.WriteFile(disk, vmProbeBootSector, 0600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(filepath.Join(runtimeDir, "qemu-system-x86_64.exe"), "-machine", "q35", "-accel", "tcg", "-cpu", "max", "-smp", "1", "-m", "128", "-display", "none", "-monitor", "none", "-serial", "file:"+serial, "-drive", "file="+qemuEscape(disk)+",format=raw,if=floppy,readonly=on", "-boot", "a")
+ cmd.Dir = runtimeDir
+ cmd.Env = vmProcessEnvironment(runtimeDir, dir)
+ cmd.SysProcAttr = vmProcessAttributes()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ start := time.Now()
+ result := runVMExecutionProbe(ctx, cmd, serial, ownVMProcess)
+ t.Logf("Actual Windows QEMU q35/TCG execution: %s in %s", result.Status, time.Since(start))
+ if result.Status != "passed" {
+ t.Fatalf("probe: %+v", result)
+ }
+}
diff --git a/app/vm_process.go b/app/vm_process.go
new file mode 100644
index 00000000..ff79637d
--- /dev/null
+++ b/app/vm_process.go
@@ -0,0 +1,199 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "sync"
+ "time"
+)
+
+type vmSession struct {
+ mu sync.Mutex
+ state VMState
+ statePath string
+ cmd *exec.Cmd
+ qmp *qmpClient
+ job io.Closer
+ done chan struct{}
+ release func()
+ forced bool
+ output *os.File
+ sessionID string
+ operationGate chan struct{}
+ displayDirectives map[string]bool
+}
+
+func startManagedVM(cmd *exec.Cmd, statePath string, state VMState, release func(), own func(*exec.Cmd) (io.Closer, error)) (*vmSession, error) {
+ sessionID, err := freshVMSessionID()
+ if err != nil {
+ return nil, err
+ }
+ session := &vmSession{sessionID: sessionID, operationGate: make(chan struct{}, 1), displayDirectives: map[string]bool{}, state: state, statePath: statePath, cmd: cmd, release: release, done: make(chan struct{})}
+ // No guest semantic channel exists: never inherit a desktop claim from disk.
+ session.state.DesktopReady = false
+ session.state.Phase = vmStarting
+ session.state.PID = 0
+ session.state.Error = ""
+ if err := writeVMState(statePath, session.state); err != nil {
+ return nil, err
+ }
+ childInput, input, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ output, childOutput, err := os.Pipe()
+ if err != nil {
+ childInput.Close()
+ input.Close()
+ return nil, err
+ }
+ cmd.Stdin = childInput
+ cmd.Stdout = childOutput
+ session.output = output
+ if err = cmd.Start(); err != nil {
+ childInput.Close()
+ input.Close()
+ output.Close()
+ childOutput.Close()
+ return nil, err
+ }
+ childInput.Close()
+ childOutput.Close()
+ session.job, err = own(cmd)
+ if err == nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ session.qmp, err = connectQMP(ctx, input, output)
+ cancel()
+ }
+ if err != nil {
+ input.Close()
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ output.Close()
+ if session.job != nil {
+ session.job.Close()
+ }
+ session.state.Phase = vmRecovery
+ session.state.Error = err.Error()
+ _ = writeVMState(statePath, session.state)
+ return nil, fmt.Errorf("VM control initialization failed: %w", err)
+ }
+ session.state.Phase = vmRunning
+ session.state.PID = cmd.Process.Pid
+ if err = writeVMState(statePath, session.state); err != nil {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ session.qmp.input.Close()
+ output.Close()
+ session.job.Close()
+ return nil, err
+ }
+ go session.wait()
+ return session, nil
+}
+
+func (s *vmSession) wait() {
+ waitErr := s.cmd.Wait()
+ _ = s.job.Close() // Close descendant handles before draining QMP EOF.
+ // The parent closed its copy of the child's write handle immediately after
+ // Start. Drain the actual EOF before deciding whether SHUTDOWN was observed.
+ <-s.qmp.done
+ _ = s.output.Close()
+ _ = s.qmp.input.Close()
+ s.mu.Lock()
+ s.state.PID = 0
+ s.state.DesktopReady = false
+ if waitErr == nil && s.qmp.clean() && !s.forced {
+ s.state.Phase = vmStopped
+ s.state.Error = ""
+ } else {
+ s.state.Phase = vmRecovery
+ s.state.Error = "The VM stopped without verified guest shutdown. Its disk is preserved; check it before reuse."
+ if waitErr != nil {
+ s.state.Error += " " + waitErr.Error()
+ }
+ }
+ if err := writeVMState(s.statePath, s.state); err != nil {
+ s.state.Phase = vmRecovery
+ s.state.Error = fmt.Sprintf("could not record VM shutdown: %v", err)
+ }
+ s.mu.Unlock()
+ s.release()
+ close(s.done)
+}
+
+func (s *vmSession) snapshot() VMState { s.mu.Lock(); defer s.mu.Unlock(); return s.state }
+
+func (s *vmSession) stop(ctx context.Context) error {
+ if err := s.acquireOperation(ctx); err != nil {
+ return err
+ }
+ locked := true
+ defer func() {
+ if locked {
+ s.releaseOperation()
+ }
+ }()
+ select {
+ case <-s.done:
+ if state := s.snapshot(); state.Phase != vmStopped {
+ return fmt.Errorf("%s", state.Error)
+ }
+ return nil
+ default:
+ }
+ s.mu.Lock()
+ if s.state.Phase == vmStopped || s.state.Phase == vmRecovery {
+ state := s.state
+ s.mu.Unlock()
+ if state.Phase == vmStopped {
+ return nil
+ }
+ return fmt.Errorf("%s", state.Error)
+ }
+ s.state.Phase = vmStopping
+ err := writeVMState(s.statePath, s.state)
+ s.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ if err = s.qmp.command(ctx, "system_powerdown"); err != nil {
+ return fmt.Errorf("request clean guest shutdown: %w", err)
+ }
+ s.releaseOperation()
+ locked = false
+ // A force stop must remain available while the guest has not shut down.
+ select {
+ case <-s.done:
+ state := s.snapshot()
+ if state.Phase != vmStopped {
+ return fmt.Errorf("%s", state.Error)
+ }
+ return nil
+ case <-ctx.Done():
+ return fmt.Errorf("Linux has not completed shutdown; its disk remains locked: %w", ctx.Err())
+ }
+}
+
+func (s *vmSession) force() error {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if err := s.acquireOperation(ctx); err != nil {
+ return err
+ }
+ defer s.releaseOperation()
+ select {
+ case <-s.done:
+ return nil
+ default:
+ }
+ s.mu.Lock()
+ s.forced = true
+ s.mu.Unlock()
+ err := s.cmd.Process.Kill()
+ <-s.done
+ return err
+}
diff --git a/app/vm_process_test.go b/app/vm_process_test.go
new file mode 100644
index 00000000..66d9881b
--- /dev/null
+++ b/app/vm_process_test.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+type noopVMJob struct{}
+
+func (noopVMJob) Close() error { return nil }
+
+// A real child process exercises inherited handles, QMP negotiation, observed
+// shutdown, and actual process reaping rather than mocking a completion flag.
+func TestVMQMPChild(t *testing.T) {
+ mode := os.Getenv("WOOTC_TEST_QMP_CHILD")
+ if mode == "" {
+ return
+ }
+ fmt.Println(`{"QMP":{"version":{},"capabilities":[]}}`)
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ var request map[string]string
+ if json.Unmarshal(scanner.Bytes(), &request) != nil {
+ os.Exit(8)
+ }
+ fmt.Printf("{\"return\":{},\"id\":%q}\n", request["id"])
+ if request["execute"] == "system_powerdown" {
+ switch mode {
+ case "clean":
+ fmt.Println(`{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}`)
+ os.Exit(0)
+ case "host":
+ fmt.Println(`{"event":"SHUTDOWN","data":{"guest":false,"reason":"host-qmp-quit"}}`)
+ os.Exit(0)
+ case "crash":
+ os.Exit(9)
+ case "hung": // A responsive QMP endpoint is not proof of guest shutdown.
+ }
+ }
+ }
+ os.Exit(7)
+}
+
+func TestManagedVMShutdownContract(t *testing.T) {
+ for _, mode := range []string{"clean", "host", "crash", "hung"} {
+ t.Run(mode, func(t *testing.T) {
+ root := t.TempDir()
+ disk := filepath.Join(root, "root.disk")
+ if err := os.WriteFile(disk, []byte("persistent user data"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ lease, err := acquireVMLock(disk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ released := make(chan struct{})
+ cmd := exec.Command(os.Args[0], "-test.run=^TestVMQMPChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_TEST_QMP_CHILD="+mode)
+ statePath := filepath.Join(root, "state.json")
+ state := VMState{InstallID: "install-123", DiskPath: disk, Phase: vmReady}
+ session, err := startManagedVM(cmd, statePath, state, func() { lease(); close(released) }, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if err != nil {
+ lease()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = session.force() })
+ if _, err := acquireVMLock(disk); err == nil {
+ t.Fatal("running image accepted a second writer")
+ }
+ if session.snapshot().DesktopReady {
+ t.Fatal("QMP liveness became desktop readiness")
+ }
+ timeout := 5 * time.Second
+ if mode == "hung" {
+ timeout = 150 * time.Millisecond
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ err = session.stop(ctx)
+ if mode == "clean" && err != nil {
+ t.Fatal(err)
+ }
+ if mode != "clean" && err == nil {
+ t.Fatal("unverified shutdown accepted")
+ }
+ if mode == "hung" {
+ select {
+ case <-released:
+ t.Fatal("timed-out guest lost writer protection")
+ default:
+ }
+ if _, err := acquireVMLock(disk); err == nil {
+ t.Fatal("hung guest accepted a writer")
+ }
+ if err := session.force(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ select {
+ case <-session.done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("child not reaped")
+ }
+ got, err := readVMState(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := vmRecovery
+ if mode == "clean" {
+ want = vmStopped
+ }
+ if got.Phase != want || got.PID != 0 || got.DesktopReady {
+ t.Fatalf("state = %+v", got)
+ }
+ next, err := acquireVMLock(disk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ next()
+ data, err := os.ReadFile(disk)
+ if err != nil || string(data) != "persistent user data" {
+ t.Fatalf("disk changed: %q %v", data, err)
+ }
+ })
+ }
+}
diff --git a/app/vm_qmp.go b/app/vm_qmp.go
new file mode 100644
index 00000000..e78dcb45
--- /dev/null
+++ b/app/vm_qmp.go
@@ -0,0 +1,301 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+type qmpResult struct {
+ value json.RawMessage
+ err error
+}
+
+// qmpClient exclusively owns the inherited pipes. No public listener exists.
+type qmpClient struct {
+ input io.WriteCloser
+ mu sync.Mutex
+ writeGate chan struct{}
+ next uint64
+ pending map[string]chan qmpResult
+ done chan struct{}
+ greeting chan error
+ cleanShutdown bool
+ err error
+}
+
+func connectQMP(ctx context.Context, input io.WriteCloser, output io.Reader) (*qmpClient, error) {
+ q := &qmpClient{input: input, writeGate: make(chan struct{}, 1), pending: map[string]chan qmpResult{}, done: make(chan struct{}), greeting: make(chan error, 1)}
+ go q.read(output)
+ select {
+ case err := <-q.greeting:
+ if err != nil {
+ input.Close()
+ return nil, err
+ }
+ case <-ctx.Done():
+ input.Close()
+ return nil, ctx.Err()
+ }
+ if err := q.command(ctx, "qmp_capabilities"); err != nil {
+ input.Close()
+ return nil, err
+ }
+ return q, nil
+}
+
+// Check duplicates recursively before decoding: encoding/json otherwise accepts
+// last-wins fields, including the response identity and nested observations.
+func qmpJSONValue(d *json.Decoder, depth int) error {
+ if depth > 32 {
+ return fmt.Errorf("QMP JSON nesting limit")
+ }
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+ if delim, ok := t.(json.Delim); ok {
+ switch delim {
+ case '{':
+ seen := map[string]bool{}
+ for d.More() {
+ k, e := d.Token()
+ if e != nil {
+ return e
+ }
+ key, ok := k.(string)
+ if !ok || seen[strings.ToLower(key)] {
+ return fmt.Errorf("duplicate QMP field")
+ }
+ seen[strings.ToLower(key)] = true
+ if e = qmpJSONValue(d, depth+1); e != nil {
+ return e
+ }
+ }
+ case '[':
+ for d.More() {
+ if e := qmpJSONValue(d, depth+1); e != nil {
+ return e
+ }
+ }
+ default:
+ return fmt.Errorf("unexpected QMP delimiter")
+ }
+ end, e := d.Token()
+ if e != nil {
+ return e
+ }
+ if (delim == '{' && end != json.Delim('}')) || (delim == '[' && end != json.Delim(']')) {
+ return fmt.Errorf("invalid QMP delimiter")
+ }
+ }
+ return nil
+}
+func decodeQMPObject(data []byte) (map[string]json.RawMessage, error) {
+ d := json.NewDecoder(bytes.NewReader(data))
+ d.UseNumber()
+ if err := qmpJSONValue(d, 0); err != nil {
+ return nil, err
+ }
+ if _, err := d.Token(); err != io.EOF {
+ return nil, fmt.Errorf("trailing QMP JSON")
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(data, &fields); err != nil || fields == nil {
+ return nil, fmt.Errorf("QMP object required")
+ }
+ return fields, nil
+}
+func qmpString(raw json.RawMessage) (string, error) {
+ var s string
+ if err := json.Unmarshal(raw, &s); err != nil || s == "" {
+ return "", fmt.Errorf("QMP string required")
+ }
+ return s, nil
+}
+func (q *qmpClient) read(output io.Reader) {
+ scanner := bufio.NewScanner(output)
+ scanner.Buffer(make([]byte, 4096), 1024*1024)
+ first := true
+ var readErr error
+ for scanner.Scan() {
+ fields, err := decodeQMPObject(scanner.Bytes())
+ if err != nil {
+ readErr = err
+ break
+ }
+ if first {
+ greeting, err := decodeQMPObject(fields["QMP"])
+ if err != nil || greeting["version"] == nil || greeting["capabilities"] == nil {
+ readErr = fmt.Errorf("QMP greeting missing")
+ break
+ }
+ if len(fields) != 1 {
+ readErr = fmt.Errorf("ambiguous QMP greeting")
+ break
+ }
+ first = false
+ q.greeting <- nil
+ continue
+ }
+ if event, ok := fields["event"]; ok {
+ name, err := qmpString(event)
+ if err != nil || fields["id"] != nil || fields["return"] != nil || fields["error"] != nil {
+ readErr = fmt.Errorf("invalid QMP event")
+ break
+ }
+ if name == "SHUTDOWN" {
+ dataFields, shapeErr := decodeQMPObject(fields["data"])
+ if shapeErr != nil || len(dataFields) != 2 || dataFields["guest"] == nil || dataFields["reason"] == nil {
+ readErr = fmt.Errorf("invalid QMP shutdown field shape")
+ break
+ }
+ var data struct {
+ Guest *bool `json:"guest"`
+ Reason string `json:"reason"`
+ }
+ if err = json.Unmarshal(fields["data"], &data); err != nil || data.Guest == nil || data.Reason == "" {
+ readErr = fmt.Errorf("invalid QMP shutdown event")
+ break
+ }
+ if *data.Guest && data.Reason == "guest-shutdown" {
+ q.mu.Lock()
+ q.cleanShutdown = true
+ q.mu.Unlock()
+ }
+ }
+ continue
+ }
+ id, err := qmpString(fields["id"])
+ value, hasReturn := fields["return"]
+ fault, hasError := fields["error"]
+ if err != nil || hasReturn == hasError || len(fields) != 2 {
+ readErr = fmt.Errorf("ambiguous QMP response")
+ break
+ }
+ result := qmpResult{value: value}
+ if hasError {
+ var e struct {
+ Class string `json:"class"`
+ Desc string `json:"desc"`
+ }
+ if err = json.Unmarshal(fault, &e); err != nil || e.Class == "" || e.Desc == "" {
+ readErr = fmt.Errorf("invalid QMP error")
+ break
+ }
+ result.err = fmt.Errorf("QMP %s: %s", e.Class, e.Desc)
+ } else if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
+ readErr = fmt.Errorf("null QMP return")
+ break
+ }
+ q.mu.Lock()
+ waiter, ok := q.pending[id]
+ if ok {
+ delete(q.pending, id)
+ waiter <- result
+ }
+ q.mu.Unlock()
+ if !ok {
+ readErr = fmt.Errorf("unmatched or replayed QMP response")
+ break
+ }
+ }
+ if readErr == nil {
+ readErr = scanner.Err()
+ }
+ if readErr == nil {
+ readErr = io.EOF
+ }
+ q.mu.Lock()
+ q.err = readErr
+ for id, waiter := range q.pending {
+ waiter <- qmpResult{err: readErr}
+ delete(q.pending, id)
+ }
+ q.mu.Unlock()
+ if first {
+ q.greeting <- readErr
+ }
+ close(q.done)
+}
+
+func (q *qmpClient) request(ctx context.Context, command string, args any) (json.RawMessage, error) {
+ switch command {
+ case "qmp_capabilities", "system_powerdown", "query-status", "query-mice", "screendump", "input-send-event":
+ default:
+ return nil, fmt.Errorf("unsupported engine QMP operation")
+ }
+ // All internal operations, including existing shutdown, have a bounded call.
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ q.mu.Lock()
+ if q.err != nil {
+ err := q.err
+ q.mu.Unlock()
+ return nil, err
+ }
+ q.next++
+ id := strconv.FormatUint(q.next, 10)
+ waiter := make(chan qmpResult, 1)
+ q.pending[id] = waiter
+ q.mu.Unlock()
+ defer func() { q.mu.Lock(); delete(q.pending, id); q.mu.Unlock() }()
+ message := map[string]any{"execute": command, "id": id}
+ if args != nil {
+ message["arguments"] = args
+ }
+ data, err := json.Marshal(message)
+ if err != nil {
+ return nil, err
+ }
+ select {
+ case q.writeGate <- struct{}{}:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ if err = ctx.Err(); err != nil {
+ <-q.writeGate
+ return nil, err
+ }
+ stopCancel := context.AfterFunc(ctx, func() { _ = q.input.Close() })
+ n, err := q.input.Write(append(data, '\n'))
+ stopCancel()
+ <-q.writeGate
+ if err != nil {
+ return nil, err
+ }
+ if n != len(data)+1 {
+ return nil, io.ErrShortWrite
+ }
+ select {
+ case result := <-waiter:
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ return result.value, result.err
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+func (q *qmpClient) command(ctx context.Context, command string) error {
+ value, err := q.request(ctx, command, nil)
+ if err != nil {
+ return err
+ }
+ obj, err := decodeQMPObject(value)
+ if err != nil || len(obj) != 0 {
+ return fmt.Errorf("QMP command requires empty object acknowledgment")
+ }
+ return nil
+}
+func (q *qmpClient) clean() bool { q.mu.Lock(); defer q.mu.Unlock(); return q.cleanShutdown }
diff --git a/app/vm_qmp_native_test.go b/app/vm_qmp_native_test.go
new file mode 100644
index 00000000..7d95229e
--- /dev/null
+++ b/app/vm_qmp_native_test.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestQMPNativePausedComponent(t *testing.T) {
+ binary, err := exec.LookPath("qemu-system-x86_64")
+ if err != nil {
+ if os.Getenv("WOOTC_REQUIRE_NATIVE_QMP") == "1" {
+ t.Fatal(err)
+ }
+ t.Skip("native QEMU unavailable; no native protocol proof")
+ }
+ version, err := exec.Command(binary, "--version").Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(strings.TrimSpace(string(version)))
+ root := t.TempDir()
+ cmd := exec.Command(binary, "-no-user-config", "-machine", "q35", "-accel", "tcg", "-S", "-m", "128", "-nodefaults", "-device", "VGA", "-display", "none", "-nic", "none", "-qmp", "stdio", "-monitor", "none")
+ state := VMState{RunID: "component", InstallID: "component", DiskID: "none-attached", DiskPath: filepath.Join(root, "unused"), Phase: vmReady}
+ s, err := startManagedVM(cmd, filepath.Join(root, "state.json"), state, func() {}, func(*exec.Cmd) (io.Closer, error) { return noopVMJob{}, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.force()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ raw, err := s.qmp.request(ctx, "query-status", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var status struct {
+ Running bool `json:"running"`
+ Status string `json:"status"`
+ }
+ if json.Unmarshal(raw, &status) != nil || status.Running || status.Status == "running" {
+ t.Fatal("paused component incorrectly running", string(raw))
+ }
+ t.Log("actual paused status", string(raw))
+ req := vmDisplayRequest{RunID: state.RunID, InstallID: state.InstallID, DiskID: state.DiskID, SessionID: s.sessionID, DirectiveID: strings.Repeat("c", 32), Action: "keys", Keys: []vmDisplayKey{{"a", true}, {"a", false}}}
+ if _, err = s.observeDisplay(ctx, req); err == nil {
+ t.Fatal("paused component accepted scoped input")
+ }
+ path, hash, width, height, err := s.captureDisplay(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hash == "" || width < 1 || height < 1 || !strings.HasPrefix(path, root) {
+ t.Fatal("native capture not verified")
+ }
+ t.Logf("actual native PPM %dx%d sha256=%s", width, height, hash)
+ if _, err = s.qmp.request(ctx, "screendump", map[string]string{"filename": root}); err == nil {
+ t.Fatal("actual native failed capture accepted")
+ }
+ if s.snapshot().DesktopReady {
+ t.Fatal("native paused component became desktop ready")
+ }
+}
diff --git a/app/vm_qmp_protocol_test.go b/app/vm_qmp_protocol_test.go
new file mode 100644
index 00000000..c375e69f
--- /dev/null
+++ b/app/vm_qmp_protocol_test.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestQMPTypedResponseCounterexamples(t *testing.T) {
+ for _, body := range []string{`{"id":"2"}`, `{"return":null,"id":"2"}`, `{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"}`, `{"return":{},"id":"2","id":"2"}`, `{"return":{"running":true,"running":false},"id":"2"}`, `{"return":{},"id":"unknown"}`, `{"error":{"class":"GenericError","desc":"failed"},"id":"2"}`} {
+ t.Run(body, func(t *testing.T) {
+ childIn, parentIn := io.Pipe()
+ parentOut, childOut := io.Pipe()
+ defer parentIn.Close()
+ defer childOut.Close()
+ defer parentOut.Close()
+ defer childIn.Close()
+ go func() {
+ fmt.Fprintln(childOut, `{"QMP":{"version":{},"capabilities":[]}}`)
+ r := bufio.NewScanner(childIn)
+ if r.Scan() {
+ fmt.Fprintln(childOut, `{"return":{},"id":"1"}`)
+ }
+ if r.Scan() {
+ fmt.Fprintln(childOut, body)
+ }
+ }()
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+ q, err := connectQMP(ctx, parentIn, parentOut)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = q.request(ctx, "query-status", nil); err == nil {
+ t.Fatal("invalid response accepted")
+ }
+ })
+ }
+}
+func TestQMPBoundedBlockedWriteAndUnsupportedCommand(t *testing.T) {
+ childIn, parentIn := io.Pipe()
+ parentOut, childOut := io.Pipe()
+ defer childIn.Close()
+ defer parentIn.Close()
+ defer childOut.Close()
+ defer parentOut.Close()
+ go fmt.Fprintln(childOut, `{"QMP":{"version":{},"capabilities":[]}}`)
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ if _, err := connectQMP(ctx, parentIn, parentOut); err == nil {
+ t.Fatal("blocked write accepted")
+ }
+ if time.Since(start) > 500*time.Millisecond {
+ t.Fatal("blocked write exceeded bound")
+ }
+ q := &qmpClient{}
+ if _, err := q.request(context.Background(), "human-monitor-command", nil); err == nil {
+ t.Fatal("arbitrary QMP accepted")
+ }
+}
+func TestQMPDuplicateOrMalformedJSON(t *testing.T) {
+ for _, body := range []string{`{"return":{"x":1,"x":2}}`, `{"a":1} {"b":2}`, `null`, `[]`, strings.Repeat("[", 34) + strings.Repeat("]", 34)} {
+ if _, err := decodeQMPObject([]byte(body)); err == nil {
+ t.Fatal("ambiguous JSON accepted", body)
+ }
+ }
+}
+
+func TestQMPShutdownNeedsLiteralTypedFields(t *testing.T) {
+ for _, data := range []string{`{"Guest":true,"reason":"guest-shutdown"}`, `{"guest":true,"Reason":"guest-shutdown"}`, `{"guest":"true","reason":"guest-shutdown"}`, `{"guest":true,"reason":"guest-shutdown","reason":"host-qmp-quit"}`} {
+ q := &qmpClient{pending: map[string]chan qmpResult{}, done: make(chan struct{}), greeting: make(chan error, 1)}
+ q.read(strings.NewReader(`{"QMP":{"version":{},"capabilities":[]}}` + "\n" + `{"event":"SHUTDOWN","data":` + data + "}\n"))
+ if q.clean() {
+ t.Fatal("malformed shutdown became clean guest stop", data)
+ }
+ }
+}
diff --git a/app/vm_qmp_test.go b/app/vm_qmp_test.go
new file mode 100644
index 00000000..7fff6622
--- /dev/null
+++ b/app/vm_qmp_test.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestQMPRejectsMissingGreetingPromptly(t *testing.T) {
+ input, output := io.Pipe()
+ defer input.Close()
+ defer output.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ _, err := connectQMP(ctx, output, strings.NewReader("{\"return\":{}}\n"))
+ if err == nil || ctx.Err() != nil {
+ t.Fatalf("bad greeting did not fail promptly: %v", err)
+ }
+}
diff --git a/app/vm_resources.go b/app/vm_resources.go
new file mode 100644
index 00000000..ffe88a7c
--- /dev/null
+++ b/app/vm_resources.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+)
+
+func qemuEscape(path string) string { return strings.ReplaceAll(path, ",", ",,") }
+func vmResourceBudget(hostGiB float64) (memoryMiB, cpus int, err error) {
+ // Keep at least half of host memory for Windows, with a 2 GiB guest floor.
+ // This is an allocation bound, not a claim about desktop responsiveness.
+ memoryMiB = int(hostGiB * 1024 / 2)
+ if memoryMiB < 2048 {
+ return 0, 0, fmt.Errorf("this VM requires at least 4 GB of host RAM; Windows must retain memory too")
+ }
+ if memoryMiB > 4096 {
+ memoryMiB = 4096
+ }
+ cpus = runtime.NumCPU() - 1
+ if cpus < 1 {
+ cpus = 1
+ }
+ if cpus > 4 {
+ cpus = 4
+ }
+ return memoryMiB, cpus, nil
+}
diff --git a/app/vm_runtime.go b/app/vm_runtime.go
new file mode 100644
index 00000000..da5898f6
--- /dev/null
+++ b/app/vm_runtime.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "wootc/internal/artifactauth"
+)
+
+// verifyVMRuntime authenticates the complete private runtime tree, including
+// dependencies loaded by QEMU. Merely hashing the main executable is not enough.
+// The release signer uses the same embedded key as boot artifacts. Canonical
+// relative paths are matched exactly; case-insensitive collisions are rejected.
+func verifyVMRuntime(root, publicKey string) error {
+ data, err := readLocalMetadata(filepath.Join(root, "SHA256SUMS"), artifactauth.MaxManifestSize)
+ if err != nil {
+ return fmt.Errorf("signed VM runtime manifest unavailable: %w", err)
+ }
+ sig, err := readLocalMetadata(filepath.Join(root, "SHA256SUMS.sig"), 64)
+ if err != nil {
+ return err
+ }
+ expected, err := artifactauth.Verify(publicKey, data, sig)
+ if err != nil {
+ return err
+ }
+ seen := map[string]bool{}
+ verified := map[string]bool{}
+ err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("runtime contains a symbolic link: %s", path)
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ if !entry.Type().IsRegular() {
+ return fmt.Errorf("runtime contains a nonregular file: %s", path)
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ name := filepath.ToSlash(relative)
+ if path == filepath.Join(root, "SHA256SUMS") || path == filepath.Join(root, "SHA256SUMS.sig") {
+ return nil
+ }
+ folded := strings.ToLower(name)
+ if seen[folded] {
+ return fmt.Errorf("duplicate runtime path: %s", name)
+ }
+ seen[folded] = true
+ checksum, ok := expected[name]
+ if !ok {
+ return fmt.Errorf("runtime file is not signed: %s", name)
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ hash := sha256.New()
+ _, err = io.Copy(hash, f)
+ f.Close()
+ if err != nil {
+ return err
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != checksum {
+ return fmt.Errorf("runtime checksum mismatch: %s", name)
+ }
+ verified[name] = true
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ for name := range expected {
+ if !verified[name] {
+ return fmt.Errorf("signed runtime file is missing: %s", name)
+ }
+ }
+ return nil
+}
diff --git a/app/vm_runtime_acquire.go b/app/vm_runtime_acquire.go
new file mode 100644
index 00000000..50330dbe
--- /dev/null
+++ b/app/vm_runtime_acquire.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "wootc/internal/runtimebundle"
+)
+
+const vmRuntimeArchive = "wootc-vm-runtime.zip"
+
+// Use the same HTTPS-only, no-ambient-proxy transport as boot artifacts. Bound
+// the download before extraction so a bad server cannot consume the host disk.
+func downloadVMRuntime(ctx context.Context, client *http.Client, source, destination string, progress func(float64)) error {
+ if err := validateArtifactURL(source); err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
+ if err != nil {
+ return err
+ }
+ response, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("VM runtime download: HTTP %d", response.StatusCode)
+ }
+ if response.ContentLength > runtimebundle.MaxArchiveBytes {
+ return fmt.Errorf("VM runtime archive exceeds the size limit")
+ }
+ file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ return err
+ }
+ complete := false
+ defer func() {
+ file.Close()
+ if !complete {
+ os.Remove(destination)
+ }
+ }()
+ buffer := make([]byte, 64*1024)
+ var written int64
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ n, readErr := response.Body.Read(buffer)
+ if n > 0 {
+ written += int64(n)
+ if written > runtimebundle.MaxArchiveBytes {
+ return fmt.Errorf("VM runtime archive exceeds the size limit")
+ }
+ if _, err := file.Write(buffer[:n]); err != nil {
+ return err
+ }
+ if progress != nil && response.ContentLength > 0 {
+ progress(float64(written) / float64(response.ContentLength))
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return readErr
+ }
+ }
+ if err := file.Sync(); err != nil {
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ complete = true
+ return nil
+}
+
+type vmRuntimeAcquisition struct {
+ checksums func(context.Context, string) (map[string]string, error)
+ download func(context.Context, string, string, func(float64)) error
+ install func(context.Context, string, string, string, string) error
+}
+
+// The caller owns the image lease and has audited parent. No root disk or
+// lifecycle state is created or removed by this operation.
+func acquireVMRuntime(ctx context.Context, parent, baseURL, publicKey string, deps vmRuntimeAcquisition, emit func(VMEvent)) error {
+ if _, err := os.Lstat(filepath.Join(parent, "qemu")); !os.IsNotExist(err) {
+ return fmt.Errorf("a VM runtime directory already exists; preserve it for verification or repair")
+ }
+ emit(VMEvent{Stage: "runtime-manifest", Message: "Checking whether this release includes Linux in a window…"})
+ sums, err := deps.checksums(ctx, filepath.Join(parent, "install"))
+ if err != nil {
+ return err
+ }
+ hash, ok := sums[vmRuntimeArchive]
+ if !ok {
+ return fmt.Errorf("this release does not include the Linux window runtime yet; use a release that includes it")
+ }
+ stage, err := os.MkdirTemp(parent, ".runtime-download-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(stage)
+ archive := filepath.Join(stage, vmRuntimeArchive)
+ emit(VMEvent{Stage: "runtime-download", Message: "Downloading the Linux window runtime…"})
+ if err := deps.download(ctx, baseURL+vmRuntimeArchive, archive, func(fraction float64) {
+ emit(VMEvent{Stage: "runtime-download", Percent: fraction * 100, Message: "Downloading the Linux window runtime…"})
+ }); err != nil {
+ return err
+ }
+ emit(VMEvent{Stage: "runtime-verify", Message: "Verifying and installing the Linux window runtime…"})
+ if err := deps.install(ctx, archive, parent, hash, publicKey); err != nil {
+ return err
+ }
+ return nil
+}
+
+// Interrupted downloads/extractions use reserved private prefixes. Hold the
+// image lease and audit parent before calling; never remove qemu or user disks.
+func cleanupVMRuntimeStaging(parent string) error {
+ for _, pattern := range []string{".runtime-download-*", ".qemu-stage-*"} {
+ paths, err := filepath.Glob(filepath.Join(parent, pattern))
+ if err != nil {
+ return err
+ }
+ for _, path := range paths {
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("could not remove interrupted runtime staging")
+ }
+ }
+ }
+ return nil
+}
diff --git a/app/vm_runtime_acquire_test.go b/app/vm_runtime_acquire_test.go
new file mode 100644
index 00000000..4d835fc3
--- /dev/null
+++ b/app/vm_runtime_acquire_test.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRuntimeAcquisitionNeedsSignedReleaseEntryBeforeDownload(t *testing.T) {
+ root := t.TempDir()
+ disk := filepath.Join(root, "root.disk")
+ os.WriteFile(disk, []byte("user work"), 0600)
+ deps := vmRuntimeAcquisition{
+ checksums: func(context.Context, string) (map[string]string, error) {
+ return map[string]string{"vmlinuz": "hash"}, nil
+ },
+ download: func(context.Context, string, string, func(float64)) error {
+ t.Fatal("downloaded an unpublished runtime")
+ return nil
+ },
+ install: func(context.Context, string, string, string, string) error {
+ t.Fatal("installed an unpublished runtime")
+ return nil
+ },
+ }
+ if err := acquireVMRuntime(context.Background(), root, "https://release.invalid/", "key", deps, func(VMEvent) {}); err == nil {
+ t.Fatal("unpublished release appeared successful")
+ }
+ data, err := os.ReadFile(disk)
+ if err != nil || string(data) != "user work" {
+ t.Fatal("acquisition touched the user disk")
+ }
+}
+func TestRuntimeAcquisitionPassesAuthenticatedHashAndCleansDownload(t *testing.T) {
+ root := t.TempDir()
+ var archive string
+ installed := false
+ deps := vmRuntimeAcquisition{
+ checksums: func(context.Context, string) (map[string]string, error) {
+ return map[string]string{vmRuntimeArchive: "authenticated-hash"}, nil
+ },
+ download: func(ctx context.Context, source, dest string, progress func(float64)) error {
+ archive = dest
+ if source != "https://release.invalid/"+vmRuntimeArchive {
+ t.Fatal(source)
+ }
+ return os.WriteFile(dest, []byte("archive"), 0600)
+ },
+ install: func(ctx context.Context, path, parent, hash, key string) error {
+ if path != archive || parent != root || hash != "authenticated-hash" || key != "embedded-key" {
+ t.Fatal("authentication identity changed")
+ }
+ installed = true
+ return nil
+ },
+ }
+ if err := acquireVMRuntime(context.Background(), root, "https://release.invalid/", "embedded-key", deps, func(VMEvent) {}); err != nil {
+ t.Fatal(err)
+ }
+ if !installed {
+ t.Fatal("no install")
+ }
+ if _, err := os.Stat(archive); !os.IsNotExist(err) {
+ t.Fatal("staged download retained")
+ }
+}
+func TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting(t *testing.T) {
+ for _, mode := range []string{"error", "oversize", "ok"} {
+ t.Run(mode, func(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch mode {
+ case "error":
+ w.WriteHeader(404)
+ case "oversize":
+ w.Header().Set("Content-Length", fmt.Sprint(1<<30))
+ default:
+ fmt.Fprint(w, "archive")
+ }
+ }))
+ defer server.Close()
+ dest := filepath.Join(t.TempDir(), "runtime.zip")
+ err := downloadVMRuntime(context.Background(), server.Client(), server.URL, dest, nil)
+ if (err == nil) != (mode == "ok") {
+ t.Fatalf("mode %s: %v", mode, err)
+ }
+ if mode != "ok" {
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Fatal("bad response left archive")
+ }
+ return
+ }
+ if err := downloadVMRuntime(context.Background(), server.Client(), server.URL, dest, nil); err == nil {
+ t.Fatal("existing archive overwritten")
+ }
+ data, _ := os.ReadFile(dest)
+ if string(data) != "archive" {
+ t.Fatal("existing bytes changed")
+ }
+ })
+ }
+}
+
+func TestRuntimeCrashCleanupPreservesPublishedRuntimeAndUserDisk(t *testing.T) {
+ root := t.TempDir()
+ for _, dir := range []string{".runtime-download-interrupted", ".qemu-stage-interrupted", "qemu", "disks"} {
+ os.Mkdir(filepath.Join(root, dir), 0700)
+ os.WriteFile(filepath.Join(root, dir, "sentinel"), []byte("keep"), 0600)
+ }
+ if err := cleanupVMRuntimeStaging(root); err != nil {
+ t.Fatal(err)
+ }
+ for _, dir := range []string{"qemu", "disks"} {
+ data, err := os.ReadFile(filepath.Join(root, dir, "sentinel"))
+ if err != nil || string(data) != "keep" {
+ t.Fatalf("cleanup changed %s", dir)
+ }
+ }
+ for _, dir := range []string{".runtime-download-interrupted", ".qemu-stage-interrupted"} {
+ if _, err := os.Stat(filepath.Join(root, dir)); !os.IsNotExist(err) {
+ t.Fatalf("retained %s", dir)
+ }
+ }
+}
+
+func TestRuntimeDownloadCancellationRemovesPartial(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", "1000000")
+ fmt.Fprint(w, "partial")
+ w.(http.Flusher).Flush()
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+ dest := filepath.Join(t.TempDir(), "runtime.zip")
+ observed := false
+ err := downloadVMRuntime(ctx, server.Client(), server.URL, dest, func(float64) { observed = true; cancel() })
+ if err == nil || !observed {
+ t.Fatalf("cancellation did not follow actual bytes: observed=%v err=%v", observed, err)
+ }
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Fatal("partial archive retained")
+ }
+}
diff --git a/app/vm_runtime_acquire_windows.go b/app/vm_runtime_acquire_windows.go
new file mode 100644
index 00000000..7ac23db4
--- /dev/null
+++ b/app/vm_runtime_acquire_windows.go
@@ -0,0 +1,72 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "golang.org/x/sys/windows"
+ "os"
+ "path/filepath"
+ "time"
+ "wootc/internal/runtimebundle"
+)
+
+// InstallVMRuntime starts only after explicit selection in the GUI or RPC.
+// It never replaces an existing runtime and never changes a Linux disk.
+func (a *App) InstallVMRuntime() error {
+ if previewMode() {
+ return fmt.Errorf("runtime installation is unavailable in the UI test harness")
+ }
+ root := wootcDir()
+ if err := prepareTrustedStateTree(root); err != nil {
+ return err
+ }
+ release, err := acquireVMLock(managedVMRootDisk())
+ if err != nil {
+ return err
+ }
+ if _, err := os.Lstat(filepath.Join(root, "qemu")); !os.IsNotExist(err) {
+ release()
+ return fmt.Errorf("a VM runtime directory already exists; preserve it for verification or repair")
+ }
+ if err := cleanupVMRuntimeStaging(root); err != nil {
+ release()
+ return err
+ }
+ rootPointer, err := windows.UTF16PtrFromString(root)
+ if err != nil {
+ release()
+ return err
+ }
+ var available, total, free uint64
+ if err := windows.GetDiskFreeSpaceEx(rootPointer, &available, &total, &free); err != nil {
+ release()
+ return err
+ }
+ if available < 2<<30 {
+ release()
+ return fmt.Errorf("runtime setup needs at least 2 GB free for its download, verified files and Windows reserve")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+ done := make(chan struct{})
+ a.vmMu.Lock()
+ a.vmCancel = cancel
+ a.vmPrepareDone = done
+ a.vmMu.Unlock()
+ go func() {
+ defer close(done)
+ defer cancel()
+ defer release()
+ defer func() { a.vmMu.Lock(); a.vmCancel = nil; a.vmPrepareDone = nil; a.vmMu.Unlock() }()
+ deps := vmRuntimeAcquisition{checksums: fetchArtifactChecksums, download: func(ctx context.Context, source, dest string, progress func(float64)) error {
+ return downloadVMRuntime(ctx, artifactClient, source, dest, progress)
+ }, install: runtimebundle.Install}
+ if err := acquireVMRuntime(ctx, root, deployerBaseURL(), artifactPublicKey, deps, a.emitVM); err != nil {
+ a.emitVM(VMEvent{Stage: "error", Message: err.Error()})
+ return
+ }
+ a.emitVM(VMEvent{Stage: "runtime-ready", Message: "The runtime is installed. Checking whether this PC can run Linux in a window…"})
+ }()
+ return nil
+}
diff --git a/app/vm_runtime_test.go b/app/vm_runtime_test.go
new file mode 100644
index 00000000..88d4086e
--- /dev/null
+++ b/app/vm_runtime_test.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "wootc/internal/artifactauth"
+)
+
+func TestVMRuntimeRequiresCompleteSignedClosure(t *testing.T) {
+ pub, private, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ key := hex.EncodeToString(pub)
+ content := []byte("trusted-runtime")
+ manifest := []byte(fmt.Sprintf("%x qemu-system-x86_64.exe\n%x required.dll\n", sha256.Sum256(content), sha256.Sum256(content)))
+ for name, data := range map[string][]byte{"qemu-system-x86_64.exe": content, "required.dll": content, "SHA256SUMS": manifest, "SHA256SUMS.sig": artifactauth.Sign(private, manifest)} {
+ if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := verifyVMRuntime(root, key); err != nil {
+ t.Fatal(err)
+ }
+ extra := filepath.Join(root, "ambient.dll")
+ if err := os.WriteFile(extra, []byte("unsigned"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyVMRuntime(root, key); err == nil {
+ t.Fatal("unsigned DLL accepted")
+ }
+ os.Remove(extra)
+ os.WriteFile(filepath.Join(root, "qemu-system-x86_64.exe"), []byte("replaced"), 0600)
+ if err := verifyVMRuntime(root, key); err == nil {
+ t.Fatal("replacement executable accepted")
+ }
+ os.WriteFile(filepath.Join(root, "qemu-system-x86_64.exe"), content, 0600)
+ if err := os.Remove(filepath.Join(root, "required.dll")); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyVMRuntime(root, key); err == nil {
+ t.Fatal("deleted signed DLL accepted as a complete runtime")
+ }
+ if err := os.WriteFile(filepath.Join(root, "required.dll"), content, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyVMRuntime(root, key); err != nil {
+ t.Fatal(err)
+ }
+ other, _, _ := ed25519.GenerateKey(rand.Reader)
+ if err := verifyVMRuntime(root, hex.EncodeToString(other)); err == nil {
+ t.Fatal("untrusted signing key accepted")
+ }
+}
diff --git a/app/vm_space.go b/app/vm_space.go
new file mode 100644
index 00000000..94771a1e
--- /dev/null
+++ b/app/vm_space.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+const vmWindowsReserveBytes = uint64(8) << 30
+
+// An admission check cannot reserve free space against other Windows apps.
+// Recheck while the helper writes, and retain the cancellation cause for the UI.
+func guardVMFreeSpace(parent context.Context, minimum uint64, interval time.Duration, sample func() (uint64, error)) (context.Context, func()) {
+ ctx, cancel := context.WithCancelCause(parent)
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ available, err := sample()
+ if err != nil {
+ cancel(fmt.Errorf("could not check the space reserved for Windows: %w", err))
+ return
+ }
+ if available < minimum {
+ cancel(fmt.Errorf("preparation stopped because Windows free space fell below the 8 GB reserve; free more space before trying again"))
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+ }()
+ return ctx, func() { cancel(nil); <-done }
+}
diff --git a/app/vm_space_test.go b/app/vm_space_test.go
new file mode 100644
index 00000000..7dff0d22
--- /dev/null
+++ b/app/vm_space_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestVMFreeSpaceGuardObservesDropAndJoins(t *testing.T) {
+ samples := make(chan uint64, 2)
+ samples <- 200
+ samples <- 50
+ observed := make(chan uint64, 2)
+ ctx, stop := guardVMFreeSpace(context.Background(), 100, time.Millisecond, func() (uint64, error) { value := <-samples; observed <- value; return value, nil })
+ defer stop()
+ select {
+ case <-ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("low space never stopped preparation")
+ }
+ if <-observed != 200 || <-observed != 50 {
+ t.Fatal("guard did not observe changing free space")
+ }
+ if !strings.Contains(context.Cause(ctx).Error(), "reserve") {
+ t.Fatal(context.Cause(ctx))
+ }
+}
+func TestVMFreeSpaceGuardStopsWithParent(t *testing.T) {
+ parent, cancel := context.WithCancel(context.Background())
+ observed := make(chan struct{}, 1)
+ _, stop := guardVMFreeSpace(parent, 100, time.Hour, func() (uint64, error) { observed <- struct{}{}; return 200, nil })
+ <-observed
+ cancel()
+ done := make(chan struct{})
+ go func() { stop(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("space monitor outlived its worker")
+ }
+}
diff --git a/app/vm_storage_profile.go b/app/vm_storage_profile.go
new file mode 100644
index 00000000..b5a38b54
--- /dev/null
+++ b/app/vm_storage_profile.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strings"
+)
+
+// Minimums describe the helper, not the capacity needs of every OCI image.
+// They never select a smaller host profile without separate image evidence.
+type vmStorageMinimums struct{ Target, Scratch uint64 }
+type vmStoragePlan struct{ Target, Scratch, Reserve uint64 }
+
+func defaultVMStoragePlan() vmStoragePlan {
+ return vmStoragePlan{Target: 40 << 30, Scratch: 40 << 30, Reserve: vmWindowsReserveBytes}
+}
+
+func parseVMStorageMinimums(data []byte) (vmStorageMinimums, error) {
+ if len(data) > 64<<10 {
+ return vmStorageMinimums{}, fmt.Errorf("helper protocol metadata exceeds size limit")
+ }
+ // Reject duplicate keys instead of allowing the JSON decoder's last-wins rule.
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ token, err := decoder.Token()
+ if err != nil || token != json.Delim('{') {
+ return vmStorageMinimums{}, fmt.Errorf("invalid helper protocol object")
+ }
+ seen := map[string]bool{}
+ for decoder.More() {
+ token, err := decoder.Token()
+ if err != nil {
+ return vmStorageMinimums{}, err
+ }
+ key, ok := token.(string)
+ key = strings.ToLower(key)
+ if !ok || seen[key] {
+ return vmStorageMinimums{}, fmt.Errorf("duplicate or invalid helper protocol field")
+ }
+ seen[key] = true
+ var value json.RawMessage
+ if err := decoder.Decode(&value); err != nil {
+ return vmStorageMinimums{}, err
+ }
+ }
+ var protocol struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Protocol string `json:"protocol"`
+ ProtocolVersion int `json:"protocolVersion"`
+ TargetSerial string `json:"targetSerial"`
+ ScratchSerial string `json:"scratchSerial"`
+ Legacy *uint64 `json:"minimumDiskBytes"`
+ Target *uint64 `json:"minimumTargetDiskBytes"`
+ Scratch *uint64 `json:"minimumScratchDiskBytes"`
+ }
+ if err := json.Unmarshal(data, &protocol); err != nil {
+ return vmStorageMinimums{}, fmt.Errorf("invalid helper storage contract: %w", err)
+ }
+ if protocol.SchemaVersion != 1 || protocol.Protocol != "wootc-helper" || protocol.ProtocolVersion != 1 || protocol.TargetSerial != "wootc-root" || protocol.ScratchSerial != "wootc-scratch" {
+ return vmStorageMinimums{}, fmt.Errorf("unsupported helper storage contract")
+ }
+ if (seen["minimumdiskbytes"] && protocol.Legacy == nil) || (seen["minimumtargetdiskbytes"] && protocol.Target == nil) || (seen["minimumscratchdiskbytes"] && protocol.Scratch == nil) {
+ return vmStorageMinimums{}, fmt.Errorf("helper disk minimums cannot be null")
+ }
+ if (protocol.Target == nil) != (protocol.Scratch == nil) {
+ return vmStorageMinimums{}, fmt.Errorf("helper disk minimums must occur as a pair")
+ }
+ if protocol.Legacy != nil && *protocol.Legacy == 0 {
+ return vmStorageMinimums{}, fmt.Errorf("helper disk minimum must be positive")
+ }
+ if protocol.Target == nil {
+ if protocol.Legacy == nil {
+ return vmStorageMinimums{}, fmt.Errorf("helper disk minimums are missing")
+ }
+ return vmStorageMinimums{*protocol.Legacy, *protocol.Legacy}, nil
+ }
+ if *protocol.Target == 0 || *protocol.Scratch == 0 {
+ return vmStorageMinimums{}, fmt.Errorf("helper disk minimums must be positive")
+ }
+ if protocol.Legacy != nil && (*protocol.Legacy < *protocol.Target || *protocol.Legacy < *protocol.Scratch) {
+ return vmStorageMinimums{}, fmt.Errorf("legacy helper minimum is not a conservative fallback")
+ }
+ return vmStorageMinimums{*protocol.Target, *protocol.Scratch}, nil
+}
+
+func (plan vmStoragePlan) requiredFree(minimums vmStorageMinimums) (uint64, error) {
+ if minimums.Target == 0 || minimums.Scratch == 0 || plan.Target < minimums.Target || plan.Scratch < minimums.Scratch || plan.Reserve < vmWindowsReserveBytes {
+ return 0, fmt.Errorf("selected VM capacity profile does not meet the helper minimums and Windows reserve")
+ }
+ if plan.Target > math.MaxInt64 || plan.Scratch > math.MaxInt64 || plan.Target > math.MaxUint64-plan.Scratch || plan.Target+plan.Scratch > math.MaxUint64-plan.Reserve {
+ return 0, fmt.Errorf("VM capacity profile exceeds supported disk sizes")
+ }
+ return plan.Target + plan.Scratch + plan.Reserve, nil
+}
+
+func (plan vmStoragePlan) admit(minimums vmStorageMinimums, available uint64) error {
+ required, err := plan.requiredFree(minimums)
+ if err != nil {
+ return err
+ }
+ if available < required {
+ return fmt.Errorf("VM preparation needs %.1f GiB free for its disks and Windows reserve; %.1f GiB is available", float64(required)/(1<<30), float64(available)/(1<<30))
+ }
+ return nil
+}
diff --git a/app/vm_storage_profile_test.go b/app/vm_storage_profile_test.go
new file mode 100644
index 00000000..f56b7e2f
--- /dev/null
+++ b/app/vm_storage_profile_test.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func storageContract(fields string) []byte {
+ return []byte(`{"schemaVersion":1,"protocol":"wootc-helper","protocolVersion":1,"targetSerial":"wootc-root","scratchSerial":"wootc-scratch",` + fields + `}`)
+}
+
+func TestVMStorageMinimumsCompatibility(t *testing.T) {
+ for _, tc := range []struct {
+ name, fields string
+ target, scratch uint64
+ }{
+ {"legacy", `"minimumDiskBytes":34359738368`, 32 << 30, 32 << 30},
+ {"pair", `"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":17179869184`, 32 << 30, 16 << 30},
+ {"pair-with-conservative-legacy", `"minimumDiskBytes":34359738368,"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":17179869184`, 32 << 30, 16 << 30},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ mins, err := parseVMStorageMinimums(storageContract(tc.fields))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mins.Target != tc.target || mins.Scratch != tc.scratch {
+ t.Fatalf("swapped or lost bounds: %+v", mins)
+ }
+ required, err := defaultVMStoragePlan().requiredFree(mins)
+ if err != nil || required != 88<<30 {
+ t.Fatalf("helper minimum silently changed unproven host profile: %d %v", required, err)
+ }
+ })
+ }
+}
+func TestVMStorageMinimumsRejectMalformedContracts(t *testing.T) {
+ for _, fields := range []string{
+ `"other":1`, `"minimumDiskBytes":0`, `"minimumDiskBytes":null`, `"minimumDiskBytes":34359738368,"minimumTargetDiskBytes":null,"minimumScratchDiskBytes":null`,
+ `"minimumTargetDiskBytes":34359738368`, `"minimumScratchDiskBytes":17179869184`,
+ `"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":0`,
+ `"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":null`,
+ `"minimumDiskBytes":17179869184,"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":17179869184`,
+ `"minimumDiskBytes":-1`, `"minimumDiskBytes":1.5`, `"minimumDiskBytes":18446744073709551616`,
+ `"minimumDiskBytes":"34359738368"`, `"minimumDiskBytes":1,"minimumDiskBytes":34359738368`, `"minimumDiskBytes":1,"MINIMUMDISKBYTES":34359738368`,
+ `"protocolVersion":2,"minimumDiskBytes":34359738368`,
+ } {
+ t.Run(fields, func(t *testing.T) {
+ if _, err := parseVMStorageMinimums(storageContract(fields)); err == nil {
+ t.Fatal("accepted invalid metadata")
+ }
+ })
+ }
+ for _, data := range [][]byte{[]byte(`[]`), []byte(`null`), []byte(`{`), append(storageContract(`"minimumDiskBytes":34359738368`), []byte(` {}`)...), []byte(strings.Repeat(" ", 65537)), []byte(strings.Replace(string(storageContract(`"minimumDiskBytes":34359738368`)), `"protocolVersion":1`, `"protocolVersion":2`, 1))} {
+ if _, err := parseVMStorageMinimums(data); err == nil {
+ t.Fatal("accepted unsupported protocol")
+ }
+ }
+}
+func TestVMStorageAdmissionBoundaries(t *testing.T) {
+ candidate := vmStoragePlan{Target: 32 << 30, Scratch: 16 << 30, Reserve: 8 << 30}
+ mins := vmStorageMinimums{Target: 32 << 30, Scratch: 16 << 30}
+ for _, plan := range []vmStoragePlan{defaultVMStoragePlan(), candidate} {
+ required, err := plan.requiredFree(mins)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := plan.admit(mins, required); err != nil {
+ t.Fatal(err)
+ }
+ if err := plan.admit(mins, required-1); err == nil {
+ t.Fatal("one-byte shortfall accepted")
+ }
+ }
+ if required, _ := candidate.requiredFree(mins); required != 56<<30 {
+ t.Fatal(required)
+ }
+ if err := candidate.admit(vmStorageMinimums{32 << 30, 32 << 30}, math.MaxUint64); err == nil {
+ t.Fatal("candidate bypassed legacy scratch minimum")
+ }
+ for _, plan := range []vmStoragePlan{
+ {31 << 30, 16 << 30, 8 << 30}, {32 << 30, (16 << 30) - 1, 8 << 30}, {32 << 30, 16 << 30, (8 << 30) - 1},
+ {math.MaxUint64, 16 << 30, 8 << 30}, {math.MaxInt64, math.MaxInt64, 8 << 30},
+ } {
+ if err := plan.admit(mins, math.MaxUint64); err == nil {
+ t.Fatal(fmt.Sprintf("invalid profile accepted: %+v", plan))
+ }
+ }
+}
+
+func TestVMCapacityWriterChild(t *testing.T) {
+ path := os.Getenv("WOOTC_TEST_CAPACITY_WRITER")
+ if path == "" {
+ return
+ }
+ file, err := os.Create(path)
+ if err != nil {
+ os.Exit(21)
+ }
+ defer file.Close()
+ for {
+ if _, err := file.Write(make([]byte, 1024)); err != nil {
+ os.Exit(22)
+ }
+ if err := file.Sync(); err != nil {
+ os.Exit(23)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func TestVMCapacityDropCancelsActualWriterBeforeLeaseRelease(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "writer.disk")
+ release, err := acquireVMLock(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ parent, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ ctx, stop := guardVMFreeSpace(parent, 100, time.Millisecond, func() (uint64, error) {
+ info, err := os.Stat(path)
+ if os.IsNotExist(err) {
+ return 200, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if info.Size() >= 4096 {
+ return 99, nil
+ }
+ return 200, nil
+ })
+ defer stop()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestVMCapacityWriterChild$")
+ cmd.Env = append(os.Environ(), "WOOTC_TEST_CAPACITY_WRITER="+path)
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ <-ctx.Done()
+ if releaseOther, err := acquireVMLock(path); err == nil {
+ releaseOther()
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ t.Fatal("writer lease released before process reap")
+ }
+ _ = cmd.Wait()
+ if !strings.Contains(context.Cause(ctx).Error(), "reserve") {
+ t.Fatal(context.Cause(ctx))
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Size() < 4096 {
+ t.Fatalf("test did not observe actual writes: %v", err)
+ }
+ if cmd.ProcessState == nil {
+ t.Fatal("child not reaped")
+ }
+ release()
+ reacquired, err := acquireVMLock(path)
+ if err != nil {
+ t.Fatalf("lease retained after reap: %v", err)
+ }
+ reacquired()
+}
diff --git a/app/vm_storage_selection.go b/app/vm_storage_selection.go
new file mode 100644
index 00000000..ce21762c
--- /dev/null
+++ b/app/vm_storage_selection.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "encoding/hex"
+ "fmt"
+ "strings"
+)
+
+// Explicit build-time opt-in only. No environment or GUI field can select an
+// unmeasured smaller profile. A release uses the conservative default.
+var vmCapacityExperiment string
+
+type vmStorageProfile struct {
+ ID string
+ Image string
+ RuntimeManifestSHA256 string
+ Plan vmStoragePlan
+}
+
+type vmStorageSelection struct {
+ ProfileID string
+ RuntimeManifestSHA256 string
+ Plan vmStoragePlan
+ Minimums vmStorageMinimums
+}
+
+// Entries require an immutable image, exact signed runtime closure and recorded
+// capacity evidence. Leave empty until the experiment has passed its gates.
+func approvedVMStorageProfiles() []vmStorageProfile { return nil }
+
+func canonicalSHA256(value string) bool {
+ decoded, err := hex.DecodeString(value)
+ return err == nil && len(decoded) == 32 && value == strings.ToLower(value)
+}
+
+func selectVMStorageProfile(experimentID, image, runtimeManifestSHA256 string, minimums vmStorageMinimums, profiles []vmStorageProfile) (vmStorageSelection, error) {
+ host, repo, digest, err := registryRef(image)
+ if err != nil || host == "" || repo == "" || !strings.Contains(image, "@sha256:") || strings.ContainsAny(image, " \t\r\n?#\\") || strings.HasPrefix(repo, "/") || !strings.HasPrefix(digest, "sha256:") || !canonicalSHA256(strings.TrimPrefix(digest, "sha256:")) {
+ return vmStorageSelection{}, fmt.Errorf("VM storage selection requires an immutable SHA-256 image reference")
+ }
+ if !canonicalSHA256(runtimeManifestSHA256) {
+ return vmStorageSelection{}, fmt.Errorf("VM storage selection requires an authenticated runtime manifest identity")
+ }
+ selection := vmStorageSelection{ProfileID: "conservative-40-40", RuntimeManifestSHA256: runtimeManifestSHA256, Plan: defaultVMStoragePlan(), Minimums: minimums}
+ if experimentID != "" {
+ found := false
+ for _, profile := range profiles {
+ if profile.ID != experimentID {
+ continue
+ }
+ if found {
+ return vmStorageSelection{}, fmt.Errorf("ambiguous VM capacity experiment")
+ }
+ found = true
+ if profile.Image != image || profile.RuntimeManifestSHA256 != runtimeManifestSHA256 {
+ return vmStorageSelection{}, fmt.Errorf("this VM capacity experiment is not approved for the selected image and runtime")
+ }
+ selection.ProfileID = profile.ID
+ selection.Plan = profile.Plan
+ }
+ if !found {
+ return vmStorageSelection{}, fmt.Errorf("unknown or unapproved VM capacity experiment")
+ }
+ }
+ if _, err := selection.Plan.requiredFree(minimums); err != nil {
+ return vmStorageSelection{}, err
+ }
+ return selection, nil
+}
diff --git a/app/vm_storage_selection_test.go b/app/vm_storage_selection_test.go
new file mode 100644
index 00000000..8b9f3105
--- /dev/null
+++ b/app/vm_storage_selection_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestVMStorageExperimentRequiresExactImageAndRuntime(t *testing.T) {
+ image := "registry.example/test/yellowfin@sha256:" + strings.Repeat("a", 64)
+ runtime := strings.Repeat("b", 64)
+ minimums := vmStorageMinimums{32 << 30, 8 << 30}
+ candidate := vmStorageProfile{ID: "yellowfin-32-8-trial", Image: image, RuntimeManifestSHA256: runtime, Plan: vmStoragePlan{32 << 30, 8 << 30, 8 << 30}}
+ profiles := []vmStorageProfile{candidate}
+ selected, err := selectVMStorageProfile(candidate.ID, image, runtime, minimums, profiles)
+ if err != nil {
+ t.Fatal(err)
+ }
+ required, err := selected.Plan.requiredFree(minimums)
+ if err != nil || required != 48<<30 || selected.ProfileID != candidate.ID {
+ t.Fatalf("wrong explicit plan: %+v %v", selected, err)
+ }
+ if err := selected.Plan.admit(minimums, 48<<30); err != nil {
+ t.Fatal(err)
+ }
+ if err := selected.Plan.admit(minimums, (48<<30)-1); err == nil {
+ t.Fatal("candidate bypasses reserve boundary")
+ }
+ for _, tc := range []struct {
+ name, id, image, runtime string
+ mins vmStorageMinimums
+ profiles []vmStorageProfile
+ }{
+ {"tag", candidate.ID, "registry.example/test/yellowfin:latest", runtime, minimums, profiles},
+ {"other digest", candidate.ID, strings.Replace(image, strings.Repeat("a", 64), strings.Repeat("c", 64), 1), runtime, minimums, profiles},
+ {"other repository", candidate.ID, strings.Replace(image, "yellowfin", "different", 1), runtime, minimums, profiles},
+ {"other runtime", candidate.ID, image, strings.Repeat("c", 64), minimums, profiles},
+ {"missing runtime", candidate.ID, image, "", minimums, profiles},
+ {"malformed runtime", candidate.ID, image, strings.Repeat("z", 64), minimums, profiles},
+ {"unapproved ID", "guess", image, runtime, minimums, profiles},
+ {"unapproved registry", candidate.ID, image, runtime, minimums, nil},
+ {"duplicate ID", candidate.ID, image, runtime, minimums, append(profiles, candidate)},
+ {"legacy scratch minimum", candidate.ID, image, runtime, vmStorageMinimums{32 << 30, 32 << 30}, profiles},
+ {"16GiB scratch minimum", candidate.ID, image, runtime, vmStorageMinimums{32 << 30, 16 << 30}, profiles},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := selectVMStorageProfile(tc.id, tc.image, tc.runtime, tc.mins, tc.profiles); err == nil {
+ t.Fatal("unmeasured or incompatible profile selected")
+ }
+ })
+ }
+ selected, err = selectVMStorageProfile("", image, runtime, minimums, profiles)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if required, _ := selected.Plan.requiredFree(minimums); required != 88<<30 {
+ t.Fatal("default profile changed")
+ }
+ if len(approvedVMStorageProfiles()) != 0 || vmCapacityExperiment != "" {
+ t.Fatal("an unproven experiment was activated")
+ }
+ if _, err := selectVMStorageProfile(candidate.ID, image, runtime, minimums, approvedVMStorageProfiles()); err == nil {
+ t.Fatal("production registry approved a test profile")
+ }
+}
+
+func TestVMStorageSelectionSurvivesDurableState(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ state := VMState{InstallID: "installation-123", RunID: "run-12345", DiskPath: "root.disk", Image: "registry.example/os@sha256:" + strings.Repeat("a", 64), StorageProfile: "conservative-40-40", TargetCapacityBytes: 40 << 30, ScratchCapacityBytes: 40 << 30, RuntimeManifestSHA256: strings.Repeat("b", 64), Phase: vmPreparing}
+ if err := writeVMState(path, state); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := readVMState(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.StorageProfile != state.StorageProfile || loaded.TargetCapacityBytes != state.TargetCapacityBytes || loaded.ScratchCapacityBytes != state.ScratchCapacityBytes || loaded.RuntimeManifestSHA256 != state.RuntimeManifestSHA256 {
+ t.Fatalf("lost provision profile identity: %+v", loaded)
+ }
+ if loaded.DesktopReady {
+ t.Fatal("capacity profile became desktop evidence")
+ }
+}
diff --git a/app/vm_windows.go b/app/vm_windows.go
index d9781a8d..ea159816 100644
--- a/app/vm_windows.go
+++ b/app/vm_windows.go
@@ -3,13 +3,15 @@
package main
import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
"fmt"
- "net"
+ "net/http"
"os"
"os/exec"
"path/filepath"
"strings"
- "syscall"
"time"
)
@@ -17,66 +19,50 @@ import (
// window on Windows using WHPX acceleration — the user sees their Linux
// system without rebooting. Same disk, same state as the dual-boot path.
//
-// QEMU-for-Windows + edk2 firmware are bundled under C:\wootc\qemu\ by the
-// release. This code is the launcher + capability probe.
+// The signed QEMU runtime and firmware are staged under C:\wootc\qemu.
+// Standard releases do not yet supply that bundle; see docs/managed-vm.md.
// VMCapability tells the GUI whether "Boot in VM" can run and why not.
type VMCapability struct {
- Available bool `json:"available"`
- Reason string `json:"reason"`
- DiskPath string `json:"diskPath"`
- Accelerator string `json:"accelerator"`
- QEMUPath string `json:"qemuPath"`
- Bundled bool `json:"bundled"`
+ Available bool `json:"available"`
+ Reason string `json:"reason"`
+ DiskPath string `json:"diskPath"`
+ Accelerator string `json:"accelerator"`
+ QEMUPath string `json:"qemuPath"`
+ Bundled bool `json:"bundled"`
+ ProbeStatus string `json:"probeStatus"`
+ RuntimeNeeded bool `json:"runtimeNeeded"`
}
func qemuDir() string { return filepath.Join(wootcDir(), "qemu") }
func qemuExe() string { return filepath.Join(qemuDir(), "qemu-system-x86_64.exe") }
-func edk2Code() string { return filepath.Join(qemuDir(), "edk2-x86_64-code.fd") }
+func edk2Code() string { return filepath.Join(qemuDir(), "share", "edk2-x86_64-code.fd") }
// GetVMCapability reports whether the installed disk can be booted in a VM.
func (a *App) GetVMCapability() VMCapability {
- info := getUninstallInfo() // reuses root.disk discovery
- if !info.Found {
- return VMCapability{Available: false, Reason: "No installed Linux disk was found."}
+ state := a.GetVMState()
+ if state.Phase != vmReady && state.Phase != vmStopped {
+ return VMCapability{DiskPath: state.DiskPath, Reason: "No verified, stopped VM installation is ready. " + state.Error}
}
- qemuPath, bundled := findQEMU()
- if qemuPath == "" {
- return VMCapability{Available: false, DiskPath: info.DiskPath,
- Reason: "QEMU isn't installed. Install QEMU for Windows or reinstall wootc with the VM viewer."}
- }
- if _, err := os.Stat(edk2Code()); err != nil {
- return VMCapability{Available: false, DiskPath: info.DiskPath,
- QEMUPath: qemuPath, Bundled: bundled,
- Reason: "The VM firmware is missing. Reinstall wootc with the VM viewer."}
+ cap := a.vmRuntimeCapability()
+ cap.DiskPath = state.DiskPath
+ if cap.Available {
+ cap = a.probeVMRuntime(cap)
}
- accelerator := availableAccelerator()
- if accelerator == "" {
- return VMCapability{Available: false, DiskPath: info.DiskPath,
- QEMUPath: qemuPath, Bundled: bundled,
- Reason: "No supported VM accelerator is available. Enable Windows Hypervisor Platform in 'Turn Windows features on or off'."}
- }
- return VMCapability{Available: true, DiskPath: info.DiskPath, Accelerator: accelerator, QEMUPath: qemuPath, Bundled: bundled}
+ return cap
}
func findQEMU() (string, bool) {
- if _, err := os.Stat(qemuExe()); err == nil {
+ if info, err := os.Stat(qemuExe()); err == nil && !info.IsDir() {
return qemuExe(), true
}
- path, err := exec.LookPath("qemu-system-x86_64.exe")
- if err != nil {
- return "", false
- }
- return path, false
+ return "", false // never execute an arbitrary elevated binary from PATH
}
func availableAccelerator() string {
if whpxAvailable() {
return "whpx"
}
- if haxmAvailable() {
- return "hax"
- }
return ""
}
@@ -88,25 +74,25 @@ func (a *App) BootInVM() error {
if !cap.Available {
return fmt.Errorf("%s", cap.Reason)
}
- args := []string{
- "-accel", cap.Accelerator,
- "-m", "4G", "-smp", "4",
- "-machine", "q35",
- "-drive", vmDiskDrive(cap.DiskPath),
- "-drive", "if=pflash,format=raw,readonly=on,file=" + edk2Code(),
- "-nic", "user,hostfwd=tcp:127.0.0.1:2222-:22",
- "-display", "gtk",
- "-name", effectiveBranding().Name + " (VM)",
- }
- cmd := exec.Command(cap.QEMUPath, args...)
- cmd.Dir = filepath.Dir(cap.QEMUPath)
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false}
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("launching QEMU: %w", err)
+ if err := prepareTrustedStateTree(wootcDir()); err != nil {
+ return err
}
- // Detach: the VM window outlives this call.
- go func() { _ = cmd.Wait() }()
- return nil
+ release, err := acquireVMLock(managedVMRootDisk())
+ if err != nil {
+ return err
+ }
+ if err := removeVMAccountInputs(previewDir()); err != nil {
+ release()
+ return err
+ }
+ state, err := readVMState(vmStatePath(wootcDir()))
+ if err == nil {
+ err = a.launchManagedDesktop(cap, state, release)
+ }
+ if err != nil {
+ release()
+ }
+ return err
}
// ── §6.1 Try in VM (fresh image, two-stage headless builder) ─────────────────
@@ -116,36 +102,46 @@ func (a *App) BootInVM() error {
// reboot, no BCD change. Native promotion remains gated until the same-image
// lifecycle and shutdown contract in ADR 0004 is proven.
-func previewDir() string { return filepath.Join(os.TempDir(), "wootc-preview") }
-func previewRaw() string { return filepath.Join(previewDir(), "preview.raw") }
+func previewDir() string { return filepath.Join(wootcDir(), "vm") }
+func previewRaw() string { return managedVMRootDisk() }
func builderKernel() string { return filepath.Join(qemuDir(), "builder-vmlinuz") }
func builderInitrd() string { return filepath.Join(qemuDir(), "builder-initramfs.img") }
-const previewIPCPort = 9099
-
// GetFreshVMCapability reports whether "Try in VM" (fresh build) can run. It
// needs QEMU + firmware + an accelerator (like §6.2) plus the bundled Alpine
// builder kernel/initramfs that does the OCI→disk work.
// VM-first is the required product direction (ADR 0004). Current standard
// releases still lack a proven runtime/builder bundle, so this capability must
// remain unavailable unless those assets are actually present.
-func (a *App) GetFreshVMCapability() VMCapability {
+func (a *App) vmRuntimeCapability() VMCapability {
+ if previewMode() {
+ return VMCapability{Reason: "VM execution is unavailable in the UI test harness."}
+ }
+ if err := prepareTrustedStateTree(wootcDir()); err != nil {
+ return VMCapability{Reason: err.Error()}
+ }
+ if _, err := os.Lstat(qemuDir()); os.IsNotExist(err) {
+ return VMCapability{RuntimeNeeded: true, Reason: "This release needs the Linux window runtime. Set it up to check whether this PC can run it."}
+ }
+ if err := verifyVMRuntime(qemuDir(), artifactPublicKey); err != nil {
+ return VMCapability{Reason: err.Error()}
+ }
qemuPath, bundled := findQEMU()
cap := VMCapability{QEMUPath: qemuPath, Bundled: bundled}
if qemuPath == "" {
- cap.Reason = "QEMU isn't installed. Reinstall wootc with the VM viewer."
+ cap.Reason = "The Linux window runtime is incomplete. Install a complete signed runtime for this release."
return cap
}
if _, err := os.Stat(edk2Code()); err != nil {
- cap.Reason = "The VM firmware is missing. Reinstall wootc with the VM viewer."
+ cap.Reason = "The Linux window firmware is missing. Install a complete signed runtime for this release."
return cap
}
- if _, err := os.Stat(builderKernel()); err != nil {
- cap.Reason = "The Try-in-VM builder image isn't bundled with this build."
+ if _, err := os.Stat(edk2VarsTemplate()); err != nil {
+ cap.Reason = "The persistent VM firmware template is missing."
return cap
}
- if _, err := os.Stat(builderInitrd()); err != nil {
- cap.Reason = "The VM builder initramfs is missing."
+ if _, _, err := vmResourceBudget(getSystemInfo().RAMGB); err != nil {
+ cap.Reason = err.Error()
return cap
}
if acc := availableAccelerator(); acc != "" {
@@ -157,126 +153,236 @@ func (a *App) GetFreshVMCapability() VMCapability {
return cap
}
-// TryInVMFresh builds preview.raw from imageRef in a headless builder VM
-// (stage 1), then launches an interactive preview window (stage 2). Progress
-// events are emitted on "vm:progress" from the builder's IPC channel.
+func (a *App) GetFreshVMCapability() VMCapability {
+ cap := a.vmRuntimeCapability()
+ if getSystemInfo().RAMGB < 6 {
+ cap.Available = false
+ cap.Reason = "This preparation profile needs at least 6 GB RAM so Windows retains memory. A lighter profile is not verified yet."
+ return cap
+ }
+ if !cap.Available {
+ return cap
+ }
+ if _, err := os.Stat(builderKernel()); err != nil {
+ cap.Available = false
+ cap.Reason = "The Try-in-VM builder image isn't bundled with this build."
+ return cap
+ }
+ if _, err := os.Stat(builderInitrd()); err != nil {
+ cap.Available = false
+ cap.Reason = "The VM builder initramfs is missing."
+ return cap
+ }
+ if _, _, err := currentVMStoragePlan(); err != nil {
+ cap.Available = false
+ cap.Reason = err.Error()
+ return cap
+ }
+ return a.probeVMRuntime(cap)
+}
+
+// TryInVMFresh provisions one persistent raw disk, then launches its VM.
+// Completion needs a matching helper receipt and actual disk identity.
// Non-blocking: returns after the worker starts, before provisioning completes.
func (a *App) TryInVMFresh(imageRef string) error {
- if imageRef == "" {
- return fmt.Errorf("no image selected")
+ return fmt.Errorf("Linux account details are required; use PrepareVM")
+}
+
+func (a *App) PrepareVM(cfg VMInstallConfig) error {
+ if err := validateVMInstallConfig(cfg); err != nil {
+ return err
}
+ hash, err := hashPassword(cfg.Password)
+ cfg.Password = ""
+ if err != nil {
+ return fmt.Errorf("could not prepare the Linux password")
+ }
+ imageRef := cfg.ImageRef
cap := a.GetFreshVMCapability()
if !cap.Available {
return fmt.Errorf("%s", cap.Reason)
}
- if err := os.MkdirAll(previewDir(), 0o755); err != nil {
- return fmt.Errorf("create workspace: %w", err)
+ if err := prepareTrustedStateTree(wootcDir()); err != nil {
+ return err
}
- // A 20GB sparse raw disk — instant on NTFS (truncate does not allocate).
- f, err := os.OpenFile(previewRaw(), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
+ release, err := acquireVMLock(managedVMRootDisk())
if err != nil {
- return fmt.Errorf("create VM disk (an existing disk is preserved): %w", err)
+ return err
}
- if err := f.Truncate(20 << 30); err != nil {
- f.Close()
- return fmt.Errorf("size preview disk: %w", err)
+ if err := removeVMAccountInputs(previewDir()); err != nil {
+ release()
+ return err
}
- f.Close()
-
+ if _, err := os.Lstat(managedVMRootDisk()); !os.IsNotExist(err) {
+ release()
+ return fmt.Errorf("an existing Linux disk is preserved; start that VM or explicitly remove it before preparing another")
+ }
+ if _, err := os.Lstat(vmStatePath(wootcDir())); !os.IsNotExist(err) {
+ release()
+ return fmt.Errorf("an existing VM installation record needs review before creating a new disk")
+ }
+ if err := os.MkdirAll(previewDir(), 0700); err != nil {
+ release()
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(managedVMRootDisk()), 0700); err != nil {
+ release()
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
+ done := make(chan struct{})
+ a.vmMu.Lock()
+ a.vmCancel = cancel
+ a.vmPrepareDone = done
+ a.vmMu.Unlock()
go func() {
- if err := a.runBuilderVM(imageRef, cap); err != nil {
- a.emitVM(VMEvent{Stage: "error", Message: err.Error()})
- return
+ defer close(done)
+ defer cancel()
+ defer func() { a.vmMu.Lock(); a.vmCancel = nil; a.vmPrepareDone = nil; a.vmMu.Unlock() }()
+ state := VMState{SchemaVersion: 1, InstallID: newVMID(), RunID: newVMID(), Image: imageRef, DiskPath: managedVMRootDisk(), Phase: vmPreparing, Username: cfg.Username, AccountOutcome: "created"}
+ err := writeVMState(vmStatePath(wootcDir()), state)
+ if err == nil {
+ state.Image, err = resolveVMImage(ctx, imageRef)
+ }
+ var storage vmStorageSelection
+ if err == nil {
+ storage, err = selectCurrentVMStorageProfile(state.Image)
+ }
+ if err == nil {
+ state.StorageProfile = storage.ProfileID
+ state.TargetCapacityBytes = storage.Plan.Target
+ state.ScratchCapacityBytes = storage.Plan.Scratch
+ state.RuntimeManifestSHA256 = storage.RuntimeManifestSHA256
+ err = writeVMState(vmStatePath(wootcDir()), state)
+ }
+ if err == nil {
+ err = createVMImageFiles(storage)
+ }
+ if err == nil {
+ err = a.runBuilderVM(ctx, cap, &state, hash)
+ hash = ""
+ }
+ if err == nil {
+ state.Phase = vmReady
+ err = writeVMState(vmStatePath(wootcDir()), state)
+ }
+ if err == nil {
+ err = ctx.Err()
}
- if err := a.launchInteractivePreview(cap); err != nil {
+ if err == nil {
+ err = a.launchManagedDesktop(cap, state, release)
+ }
+ if err != nil {
+ if state.DiskID == "" {
+ state.Phase = vmFailed
+ } else {
+ // Preparation completed. Preserve its ready state if launch
+ // failed before QEMU touched the disk, or its recovery record
+ // if control setup failed after process creation.
+ if recorded, readErr := readVMState(vmStatePath(wootcDir())); readErr == nil {
+ state = recorded
+ }
+ if state.Phase == vmStarting {
+ state.Phase = vmRecovery
+ }
+ }
+ state.Error = err.Error()
+ _ = writeVMState(vmStatePath(wootcDir()), state)
+ release()
a.emitVM(VMEvent{Stage: "error", Message: err.Error()})
return
}
- a.emitVM(VMEvent{Stage: "started",
- Message: "The VM process started. Check its window; Linux desktop readiness is not yet verified."})
+ a.emitVM(VMEvent{Stage: "started", Message: "Your persistent Linux VM is running. Desktop and account readiness are not yet verified."})
}()
return nil
}
-// runBuilderVM launches the headless Alpine builder and blocks until it powers
-// off (bootc install done) or fails. It serves an IPC socket the guest connects
-// to for progress; the guest sends newline-delimited JSON {"step","pct"}.
-func (a *App) runBuilderVM(imageRef string, cap VMCapability) error {
- ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", previewIPCPort))
- if err != nil {
- return fmt.Errorf("open IPC socket: %w", err)
+func newVMID() string {
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ panic(err)
}
- defer ln.Close()
-
- a.emitVM(VMEvent{Stage: "pulling", Percent: 2, Message: "Starting builder…"})
+ return hex.EncodeToString(id[:])
+}
- args := []string{
- "-accel", cap.Accelerator,
- "-display", "none",
- "-m", "2G", "-smp", "2",
- "-machine", "q35",
- "-kernel", builderKernel(),
- "-initrd", builderInitrd(),
- "-append", "console=ttyS0 quiet wootc.image=" + imageRef,
- "-drive", vmDiskDrive(previewRaw()),
- "-chardev", fmt.Sprintf("socket,id=ipc,host=127.0.0.1,port=%d", previewIPCPort),
- "-device", "virtio-serial",
- "-device", "virtserialport,chardev=ipc,name=wootc.ipc",
- "-serial", "null",
- }
- cmd := exec.Command(cap.QEMUPath, args...)
- cmd.Dir = filepath.Dir(cap.QEMUPath)
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("launch builder VM: %w", err)
+func resolveVMImage(ctx context.Context, image string) (string, error) {
+ host, repo, ref, err := registryRef(image)
+ if err != nil {
+ return "", err
}
+ puller := &ociPuller{client: &http.Client{Timeout: 60 * time.Second}, host: host, repo: repo}
+ if err = puller.authorize(ctx); err != nil {
+ return "", err
+ }
+ _, digest, err := puller.fetchManifest(ctx, ref)
+ if err != nil {
+ return "", err
+ }
+ return host + "/" + repo + "@" + digest, nil
+}
- // Read guest progress until it disconnects; QEMU exits when the guest
- // powers off after a clean bootc install.
- done := make(chan struct{})
- var result builderResult
- go func() {
- defer close(done)
- conn, err := acceptWithTimeout(ln, 90*time.Second)
- if err != nil {
- result.readErr = err
- return
+func (a *App) runBuilderVM(ctx context.Context, cap VMCapability, state *VMState, passwordHash string) (resultErr error) {
+ ctx, stopSpaceGuard := guardVMFreeSpace(ctx, vmWindowsReserveBytes, time.Second, func() (uint64, error) { return vmFreeBytes(wootcDir()) })
+ defer stopSpaceGuard()
+ accountPath, err := writeVMAccountInput(previewDir(), vmAccountInput{SchemaVersion: 1, RunID: state.RunID, InstallID: state.InstallID, Username: state.Username, PasswordHash: passwordHash})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) && resultErr == nil {
+ resultErr = fmt.Errorf("could not remove private account input")
}
- defer conn.Close()
- result = readBuilderProgress(conn, a.emitVM)
}()
-
- waitErr := cmd.Wait()
- <-done
- if err := result.verify(waitErr); err != nil {
+ logPath := filepath.Join(previewDir(), state.RunID+"-builder.log")
+ serialPath := filepath.Join(previewDir(), state.RunID+"-serial.log")
+ a.emitVM(VMEvent{Stage: "pulling", Message: "Preparing your persistent Linux system. Windows will remain available."})
+ args := []string{"-accel", cap.Accelerator, "-display", "none", "-m", "3072", "-smp", "2", "-machine", "q35", "-cpu", "max",
+ "-kernel", builderKernel(), "-initrd", builderInitrd(),
+ "-append", "console=ttyS0 quiet wootc.image=" + state.Image + " wootc.run_id=" + state.RunID + " wootc.install_id=" + state.InstallID + " wootc.account_mode=create",
+ "-fw_cfg", "name=opt/wootc/install,file=" + qemuEscape(accountPath),
+ "-drive", vmDiskDrive(state.DiskPath) + ",serial=wootc-root",
+ "-drive", vmDiskDrive(filepath.Join(previewDir(), "scratch.disk")) + ",serial=wootc-scratch",
+ "-nic", "user", "-chardev", "file,id=ipc,path=" + qemuEscape(logPath), "-device", "virtio-serial",
+ "-device", "virtserialport,chardev=ipc,name=wootc.ipc", "-serial", "file:" + serialPath}
+ cmd := exec.CommandContext(ctx, cap.QEMUPath, args...)
+ cmd.Dir = filepath.Dir(cap.QEMUPath)
+ cmd.Env = vmProcessEnvironment(cmd.Dir, previewDir())
+ cmd.SysProcAttr = vmProcessAttributes()
+ stderr, err := os.OpenFile(filepath.Join(previewDir(), state.RunID+"-builder-error.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
return err
}
- if fi, err := os.Stat(previewRaw()); err != nil || fi.Size() == 0 {
- return fmt.Errorf("builder produced no disk image")
+ defer stderr.Close()
+ cmd.Stderr = stderr
+ if err = cmd.Start(); err != nil {
+ return err
}
- return nil
-}
-
-// launchInteractivePreview opens the built preview.raw in a visible QEMU window.
-func (a *App) launchInteractivePreview(cap VMCapability) error {
- a.emitVM(VMEvent{Stage: "booting", Percent: 95, Message: "Starting the preview…"})
- args := []string{
- "-accel", cap.Accelerator,
- "-m", "4G", "-smp", "4",
- "-machine", "q35",
- "-drive", vmDiskDrive(previewRaw()),
- "-drive", "if=pflash,format=raw,readonly=on,file=" + edk2Code(),
- "-nic", "user,hostfwd=tcp:127.0.0.1:2223-:22",
- "-display", "gtk",
- "-name", effectiveBranding().Name + " (VM)",
- }
- cmd := exec.Command(cap.QEMUPath, args...)
- cmd.Dir = filepath.Dir(cap.QEMUPath)
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false}
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("launch preview: %w", err)
+ job, err := ownVMProcess(cmd)
+ if err != nil {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ return err
+ }
+ defer job.Close()
+ if err = cmd.Wait(); err != nil {
+ if cause := context.Cause(ctx); cause != nil {
+ return cause
+ }
+ return fmt.Errorf("VM preparation did not complete: %w", err)
+ }
+ if err = context.Cause(ctx); err != nil {
+ return err
+ }
+ log, err := os.Open(logPath)
+ if err != nil {
+ return err
+ }
+ defer log.Close()
+ receipt, err := verifyVMBuilderReceipt(log, *state)
+ if err != nil {
+ return err
}
- go func() { _ = cmd.Wait() }()
+ state.DiskID = receipt.DiskID
return nil
}
@@ -286,14 +392,6 @@ func (a *App) InstallPreviewForReal(cfg InstallConfig) error {
return fmt.Errorf("native promotion is not available yet: keep your VM disk; shutdown and boot compatibility checks are still required")
}
-// acceptWithTimeout accepts one connection or returns after d.
-func acceptWithTimeout(ln net.Listener, d time.Duration) (net.Conn, error) {
- if tl, ok := ln.(*net.TCPListener); ok {
- _ = tl.SetDeadline(time.Now().Add(d))
- }
- return ln.Accept()
-}
-
func haxmAvailable() bool {
out, err := runPowerShellOutput(`(Get-Service -Name intelhaxm -ErrorAction SilentlyContinue).Status`)
return err == nil && strings.TrimSpace(out) == "Running"
diff --git a/app/vmprobe/serial-boot.S b/app/vmprobe/serial-boot.S
new file mode 100644
index 00000000..3ff78afd
--- /dev/null
+++ b/app/vmprobe/serial-boot.S
@@ -0,0 +1,35 @@
+.code16
+.global _start
+_start:
+ cli
+ xor %ax, %ax
+ mov %ax, %ds
+ mov %ax, %ss
+ mov $0x7c00, %sp
+ mov $0x3fb, %dx
+ mov $0x80, %al
+ out %al, %dx
+ mov $0x3f8, %dx
+ mov $1, %al
+ out %al, %dx
+ inc %dx
+ xor %al, %al
+ out %al, %dx
+ mov $0x3fb, %dx
+ mov $3, %al
+ out %al, %dx
+ mov $message, %si
+next:
+ lodsb
+ test %al, %al
+ jz done
+ mov $0x3f8, %dx
+ out %al, %dx
+ jmp next
+done:
+ hlt
+ jmp done
+message:
+ .asciz "WOOTC_WHPX_BOOTSECTOR_EXECUTED\r\n"
+.org 510
+.word 0xaa55
diff --git a/app/vmprobe/serial-boot.img b/app/vmprobe/serial-boot.img
new file mode 100755
index 00000000..ada18dea
Binary files /dev/null and b/app/vmprobe/serial-boot.img differ
diff --git a/docs/experiments/2026-09-26-vm-capacity-plan.md b/docs/experiments/2026-09-26-vm-capacity-plan.md
new file mode 100644
index 00000000..5c74577c
--- /dev/null
+++ b/docs/experiments/2026-09-26-vm-capacity-plan.md
@@ -0,0 +1,193 @@
+# Smaller VM capacity trial
+
+Status: proposed experiment. No production default changes.
+
+The current host profile creates two sparse 40 GiB files and needs 88 GiB
+free on the Windows volume. This sum includes an 8 GiB Windows reserve.
+The helper at `0cb8776` rejects either disk below 32 GiB. The signed account
+bundle remains unchanged. A 32 GiB target plus 16 GiB scratch is not valid
+under that helper contract.
+
+## Proposed contract
+
+The helper owner must approve the field names and version before the host
+uses them. Use separate `minimumTargetDiskBytes` and
+`minimumScratchDiskBytes` in the signed `builder-protocol.json`. The values
+are lower bounds for the helper, not proof that every image fits.
+
+Keep the current 32 GiB bound for each disk in the first contract change.
+A later experimental profile can reduce the scratch bound to 16 GiB after
+its tests pass. A legacy contract with `minimumDiskBytes` keeps that value
+for both disks. Do not interpret a missing field as zero.
+
+The host must verify the signed runtime before it reads this contract.
+It must reject unknown versions, invalid bounds, zero or negative values,
+numeric overflow, and unsupported disk sizes. Both separate fields must
+exist together. If the legacy field also exists, it must be at least as
+large as each separate bound. Thus legacy 32 with separate 32/16 is valid.
+
+A profile must bind its image digest and helper version to the evidence.
+Do not apply a smaller image's capacity result to any other OCI reference.
+
+## Experiment order
+
+1. Wait for the active desktop test and Windows writer to finish.
+2. Validate the new helper contract with separate bounds at 32/32 GiB.
+3. Use new, dedicated 32/16 GiB disks for a named experimental profile.
+ Keep both serial checks, blank-disk checks and mount checks. Validate
+ both disks before the first format operation.
+4. Pin the repaired image digest. Use account creation through the private
+ input channel. Retain the complete receipt and helper process exit.
+5. Boot the same target with its persistent firmware variables. Sign in,
+ create a document, shut down cleanly, restart and read the document.
+6. Repeat with the app on Windows. Do not infer Windows behavior or speed
+ from the KVM helper trial.
+
+Use a fixture with enough physical storage for the chosen upper bound.
+A larger thin volume does not create physical capacity. The current Windows
+clone has an 80 GiB logical volume and cannot pass the 88 GiB profile.
+Its shared storage's reported 35 GiB free does not prove safe expansion.
+
+## Measurements
+
+Record a sample with its timestamp each second through the full trial:
+
+| Layer | Observable |
+|---|---|
+| Windows | Volume free bytes; allocated bytes of each sparse file; QEMU memory |
+| Helper | Target and scratch capacity, used bytes and free bytes |
+| Hypervisor | Actual allocated bytes and backing-volume free bytes |
+| Desktop | Free bytes after login, document creation and clean restart |
+
+Record peak use and the final sizes of the files. Keep the same image digest,
+helper hash, QEMU version, RAM, CPU settings and sample interval with the
+results. A missing sample period is a limit on the result.
+
+The candidate admission bound is 32 + 16 + 8 = 56 GiB, after runtime setup.
+This is an experimental bound, not a proven product minimum. Account for
+host logs and temporary files as well. Measure physical use before any
+future policy relies on sparse allocation instead of full virtual capacity.
+
+## Host changes after approval
+
+`app/vm_image_windows.go` needs separate target and scratch capacities.
+Its free-space check must use checked addition of both capacities and the
+Windows reserve. Its file creation must retain exclusive creation, sparse
+file setup, exact logical lengths, sync, and close checks.
+
+`app/vm_windows.go` must select the verified image profile before it creates
+files. Store the chosen profile and capacities with the installation record.
+A restart must preserve the existing disk size and GPT identity. It must
+never resize an existing disk to match a new default.
+
+`app/vm_space.go` samples the free space in Windows during preparation. Keep its
+cancellation cause and wait for the helper process to exit before the host releases the writer
+lock. A sample every second cannot promise that another
+Windows app never consumes the reserve between samples.
+
+The host retains scratch after success. Reclaim it only after the helper
+has exited, its job has closed, and the verified receipt is durable. Delete
+only the known scratch path under the protected tree and image lock. Never
+delete the target or firmware variables. Preserve failed-run scratch until
+a policy for recovery exists.
+
+## Targeted acceptance tests
+
+| Case | Required observable |
+|---|---|
+| Legacy 32 GiB metadata | Both minimums remain 32 GiB; 16 GiB scratch rejected |
+| Separate 32/32 GiB metadata | Same admission and disk behavior as legacy |
+| Missing or invalid bounds | Rejection before any disk write |
+| Candidate 32/16 profile | Accepted only for the measured immutable image |
+| Exact admission boundary | 56 GiB accepted; one byte less rejected |
+| Sum overflow | Rejection before file creation |
+| Wrong scratch serial or existing signature | No format operation on either disk |
+| Scratch one byte below its minimum | No format operation on either disk |
+| Existing target | Contents and length unchanged after refusal |
+| Real low-space transition during helper writes | Child exits; lock retained until reaped; no success receipt accepted |
+| Clean helper completion | Durable target identity precedes scratch removal |
+| Failed helper or malformed receipt | Target preserved; no ready or desktop-ready status |
+| Restart after a new default | Same target, firmware, account and saved document |
+
+Mutation checks must remove a bounds check, swap the two bounds, release the
+lock early, or accept a success marker without its receipt. Each mutation
+must fail its corresponding test. Filesystem creation alone is not evidence
+of a completed install or a usable desktop.
+
+## Read-only fixture inventory
+
+The Windows inventory found no active QEMU process. It measured
+53,979,086,848 bytes free (50.272 GiB). The files from our experiments
+consume 4,576,790,880 bytes (4.262 GiB). Even removal of all these files
+would leave a 1,573,664,416-byte shortfall below 56 GiB.
+
+| Artifact | Allocated bytes | Disposition |
+|---|---:|---|
+| Partial helper target | 2,717,515,776 | Preserve; no completed helper receipt |
+| Installed QEMU | 1,255,998,954 | Keep until a replacement runtime is validated |
+| Old probe directory | 267,311,706 | Archive and check test ownership before any removal |
+| Cached QEMU installer | 206,615,928 | Host copy exists; removal alone cannot resolve the shortfall |
+
+The inventory did not delete files. Logs are already archived by the runner.
+The raw [inventory](evidence/2026-09-26-managed-vm/fixture-space-inventory.json)
+and [disk geometry](evidence/2026-09-26-managed-vm/fixture-disk-geometry.json)
+record the measurements. There is no duplicate runtime ZIP in this clone.
+
+The virtual disk is 95,156,174,848 bytes. The C: partition ends before an
+822,083,584-byte recovery partition. Windows reports the current C: size
+as its maximum supported size. Windows cannot use the space after recovery to extend C: directly. This agrees with the [Windows extension rules](https://learn.microsoft.com/en-us/windows-server/storage/disk-management/extend-a-basic-volume).
+
+The shared filesystem sample had 37,363,535,872 bytes free (34.8 GiB).
+After the cluster's 26 GiB guard, only 8.8 GiB remained for new writes.
+This cannot cover the candidate's 48 GiB of virtual target and scratch.
+The `local-path` StorageClass does not enable volume expansion. A change
+to its PVC size would not prove more physical capacity. See the
+[KubeVirt storage requirements](https://kubevirt.io/user-guide/storage/disks_and_volumes/).
+
+A concrete alternative is a fresh Windows fixture with at least a 128 GiB
+virtual disk. Keep the old fixture and its evidence. First add at least
+100 GiB of physical filesystem capacity on the storage node. At the measured
+free space, that would give about 134.8 GiB before new writes.
+
+Budget 32 GiB for Windows and 48 GiB for target and scratch.
+Add 8 GiB for the Windows reserve and 8 GiB for fixture assets and logs.
+Keep 26 GiB for the cluster guard. This totals 122 GiB, with about 12.8 GiB
+margin in that sample. Recheck the free space after the tests for capacity finish.
+This calculation is a plan, not a reservation.
+
+Use a new volume large enough to expose the full virtual disk. Confirm
+its size in Windows after creation. Measure C: free bytes before admission.
+Do not alter the old recovery partition to get test space. A sparse file does not satisfy the physical budget.
+Neither does a larger size in the PVC request.
+
+## Smaller image-bound hypothesis
+
+Infrastructure expansion remains a fallback. The next experiment uses the
+32/16 GiB trial as a control. It then tests a separate 32/8 GiB helper
+profile for the exact digest of repaired Yellowfin. Its 8 GiB scratch hypothesis comes
+from the earlier measurement of about 6.2 GiB for scratch. That observation
+does not prove that the smaller disk will complete a fresh install.
+
+The candidate needs 32 + 8 + 8 = 48 GiB free in Windows. A new helper must
+explicitly support that scratch size and restrict its experimental profile
+to the selected immutable image. Keep the generic helper and old archives
+unchanged. No Windows trial starts before guest measurements pass and the
+cluster has enough free space above its 26 GiB guard.
+
+The host selector uses an experiment ID set at build time. Its approved
+registry is empty. An entry must bind the full image reference and digest
+to the SHA-256 of the signed runtime manifest. This binds the helper,
+kernel, QEMU and protocol metadata as one tested set of files.
+
+A tag, changed digest, different repository, changed runtime, unknown ID,
+or incompatible helper minimum must fail before disk creation. An empty
+experiment ID keeps the conservative profile. There is no environment or
+GUI override. The selected profile, capacities and manifest identity go into
+the durable state before allocation. These fields describe the provision
+operation; they are not desktop readiness evidence.
+
+The tests cover exact 48 GiB admission, a one-byte shortfall, each identity
+mismatch, legacy and 16 GiB scratch rejection, and durable state retention.
+A mutation that removes the image comparison must fail both the other-image
+and other-repository cases. These tests do not activate a profile or prove
+that an actual image fits.
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/fixture-disk-geometry.json b/docs/experiments/evidence/2026-09-26-managed-vm/fixture-disk-geometry.json
new file mode 100644
index 00000000..e195cd31
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/fixture-disk-geometry.json
@@ -0,0 +1,51 @@
+{
+ "supportedC": {
+ "sizeMax": 84520468480,
+ "sizeMin": 30600552448
+ },
+ "partitions": [
+ {
+ "DiskNumber": 0,
+ "PartitionNumber": 1,
+ "DriveLetter": null,
+ "Offset": 1048576,
+ "Size": 536870912,
+ "Type": "System"
+ },
+ {
+ "DiskNumber": 0,
+ "PartitionNumber": 2,
+ "DriveLetter": null,
+ "Offset": 537919488,
+ "Size": 16777216,
+ "Type": "Reserved"
+ },
+ {
+ "DiskNumber": 0,
+ "PartitionNumber": 3,
+ "DriveLetter": "C",
+ "Offset": 554696704,
+ "Size": 84520468480,
+ "Type": "Basic"
+ },
+ {
+ "DiskNumber": 0,
+ "PartitionNumber": 4,
+ "DriveLetter": null,
+ "Offset": 85075165184,
+ "Size": 822083584,
+ "Type": "Recovery"
+ }
+ ],
+ "disks": [
+ {
+ "Number": 0,
+ "FriendlyName": "QEMU QEMU HARDDISK",
+ "PartitionStyle": "GPT",
+ "Size": 95156174848,
+ "AllocatedSize": 85898297344,
+ "IsBoot": true,
+ "IsSystem": true
+ }
+ ]
+}
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/fixture-space-inventory.json b/docs/experiments/evidence/2026-09-26-managed-vm/fixture-space-inventory.json
new file mode 100644
index 00000000..d2e8fc52
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/fixture-space-inventory.json
@@ -0,0 +1,180 @@
+{
+ "experiments": [
+ {
+ "logicalBytes": 47369411162,
+ "path": "C:\\Windows\\Temp\\wootc-phase1-probe",
+ "allocatedBytes": 267311706,
+ "largeFiles": [
+ {
+ "logicalBytes": 94832515,
+ "path": "C:\\Windows\\Temp\\wootc-phase1-probe\\disk-backed-probe-initramfs.img",
+ "allocatedBytes": 94832515,
+ "modifiedUTC": "2026-09-26T17:26:39.2716499Z"
+ },
+ {
+ "logicalBytes": 21474836480,
+ "path": "C:\\Windows\\Temp\\wootc-phase1-probe\\experiment-scratch.raw",
+ "allocatedBytes": 142540800,
+ "modifiedUTC": "2026-09-26T17:51:48.5800102Z"
+ },
+ {
+ "logicalBytes": 25769803776,
+ "path": "C:\\Windows\\Temp\\wootc-phase1-probe\\experiment-target.raw",
+ "allocatedBytes": 0,
+ "modifiedUTC": "2026-09-26T17:29:47.2042830Z"
+ }
+ ],
+ "fileCount": 39
+ },
+ {
+ "logicalBytes": 43052725817,
+ "path": "C:\\Windows\\Temp\\wootc-production-helper",
+ "allocatedBytes": 2820568633,
+ "largeFiles": [
+ {
+ "logicalBytes": 91185367,
+ "path": "C:\\Windows\\Temp\\wootc-production-helper\\builder-initramfs.img",
+ "allocatedBytes": 91185367,
+ "modifiedUTC": "2026-09-26T18:08:10.9261067Z"
+ },
+ {
+ "logicalBytes": 42949672960,
+ "path": "C:\\Windows\\Temp\\wootc-production-helper\\root.disk",
+ "allocatedBytes": 2717515776,
+ "modifiedUTC": "2026-09-26T19:30:25.1098281Z"
+ }
+ ],
+ "fileCount": 12
+ },
+ {
+ "logicalBytes": 206615928,
+ "path": "C:\\Windows\\Temp\\qemu-w64-setup-20260811.exe",
+ "allocatedBytes": 206615928,
+ "largeFiles": [
+ {
+ "logicalBytes": 206615928,
+ "path": "C:\\Windows\\Temp\\qemu-w64-setup-20260811.exe",
+ "allocatedBytes": 206615928,
+ "modifiedUTC": "2026-09-26T17:13:16.9759705Z"
+ }
+ ],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 10444800,
+ "path": "C:\\Windows\\Temp\\wootc-console-mutant.test.exe",
+ "allocatedBytes": 10444800,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 10446336,
+ "path": "C:\\Windows\\Temp\\wootc-console-redirect.test.exe",
+ "allocatedBytes": 10446336,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 1849856,
+ "path": "C:\\Windows\\Temp\\wootc-gui-status-attach-probe.exe",
+ "allocatedBytes": 1849856,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 1853952,
+ "path": "C:\\Windows\\Temp\\wootc-gui-status-fixed-probe.exe",
+ "allocatedBytes": 1853952,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 1665024,
+ "path": "C:\\Windows\\Temp\\wootc-gui-status-probe.exe",
+ "allocatedBytes": 1665024,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 33895,
+ "path": "C:\\Windows\\Temp\\wootc-production-helper-evidence.zip",
+ "allocatedBytes": 33895,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 0,
+ "path": "C:\\Windows\\Temp\\wootc-qemu-download.log",
+ "allocatedBytes": 0,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 1632,
+ "path": "C:\\Windows\\Temp\\wootc-qemu-install.log",
+ "allocatedBytes": 1632,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 6,
+ "path": "C:\\Windows\\Temp\\wootc-qemu-install.status",
+ "allocatedBytes": 6,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 6,
+ "path": "C:\\Windows\\Temp\\wootc-supervisor-trial-child.txt",
+ "allocatedBytes": 6,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 146,
+ "path": "C:\\Windows\\Temp\\wootc-whpx-enable.log",
+ "allocatedBytes": 146,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 6,
+ "path": "C:\\Windows\\Temp\\wootc-whpx-enable.status",
+ "allocatedBytes": 6,
+ "largeFiles": [],
+ "fileCount": 1
+ },
+ {
+ "logicalBytes": 1255998954,
+ "path": "C:\\Program Files\\qemu",
+ "allocatedBytes": 1255998954,
+ "largeFiles": [
+ {
+ "logicalBytes": 67108864,
+ "path": "C:\\Program Files\\qemu\\share\\edk2-aarch64-code.fd",
+ "allocatedBytes": 67108864,
+ "modifiedUTC": "2026-08-11T19:20:44.0000000Z"
+ },
+ {
+ "logicalBytes": 67108864,
+ "path": "C:\\Program Files\\qemu\\share\\edk2-arm-code.fd",
+ "allocatedBytes": 67108864,
+ "modifiedUTC": "2026-08-11T19:20:44.0000000Z"
+ },
+ {
+ "logicalBytes": 67108864,
+ "path": "C:\\Program Files\\qemu\\share\\edk2-arm-vars.fd",
+ "allocatedBytes": 67108864,
+ "modifiedUTC": "2026-08-11T19:20:46.0000000Z"
+ }
+ ],
+ "fileCount": 3385
+ }
+ ],
+ "utc": "2026-09-26T19:39:07.1258692Z",
+ "volume": {
+ "sizeBytes": 84520464384,
+ "freeBytes": 53979086848
+ },
+ "qemuProcesses": []
+}
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/native-account-tests.log b/docs/experiments/evidence/2026-09-26-managed-vm/native-account-tests.log
new file mode 100644
index 00000000..dac80033
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/native-account-tests.log
@@ -0,0 +1,48 @@
+Verified SHA256 348b79fba4c8dbdd58a671311d004c96c55063a39bc8e44f7e47841289c35ff5
+=== RUN TestVMAccountInputIsPrivateTransientAndPreservesDisk
+--- PASS: TestVMAccountInputIsPrivateTransientAndPreservesDisk (0.03s)
+=== RUN TestVMAccountValidationDoesNotEchoPassword
+--- PASS: TestVMAccountValidationDoesNotEchoPassword (0.00s)
+=== RUN TestVMReceiptRequiresCreatedAccountIdentity
+--- PASS: TestVMReceiptRequiresCreatedAccountIdentity (0.01s)
+=== RUN TestVMOwnedJobReapsChildOnClose
+--- PASS: TestVMOwnedJobReapsChildOnClose (0.17s)
+=== RUN TestVMEnvironmentDoesNotInheritUserPluginPaths
+--- PASS: TestVMEnvironmentDoesNotInheritUserPluginPaths (0.00s)
+=== RUN TestVMReceiptRejectsProxySuccess
+=== RUN TestVMReceiptRejectsProxySuccess/good
+=== RUN TestVMReceiptRejectsProxySuccess/only-marker
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-run
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-image
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-disk
+=== RUN TestVMReceiptRejectsProxySuccess/no-efi
+=== RUN TestVMReceiptRejectsProxySuccess/duplicate
+=== RUN TestVMReceiptRejectsProxySuccess/late-error
+=== RUN TestVMReceiptRejectsProxySuccess/early-marker
+--- PASS: TestVMReceiptRejectsProxySuccess (0.01s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/good (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/only-marker (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-run (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-image (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-disk (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/no-efi (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/duplicate (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/late-error (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/early-marker (0.00s)
+=== RUN TestVMDurableStateAndNativeWriterGuard
+--- PASS: TestVMDurableStateAndNativeWriterGuard (0.04s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (0.66s)
+ --- PASS: TestManagedVMShutdownContract/clean (0.12s)
+ --- PASS: TestManagedVMShutdownContract/host (0.12s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.12s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.29s)
+=== RUN TestQMPRejectsMissingGreetingPromptly
+--- PASS: TestQMPRejectsMissingGreetingPromptly (0.00s)
+=== RUN TestVMRuntimeRequiresCompleteSignedClosure
+--- PASS: TestVMRuntimeRequiresCompleteSignedClosure (0.05s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/native-acquisition-tests.log b/docs/experiments/evidence/2026-09-26-managed-vm/native-acquisition-tests.log
new file mode 100644
index 00000000..957a536c
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/native-acquisition-tests.log
@@ -0,0 +1,22 @@
+Verified SHA256 d36dd9ee5f84f6da6be938add9b02c4ee890dc8fa643f6545b3809da3bd4ba08
+=== RUN TestRuntimeAcquisitionNeedsSignedReleaseEntryBeforeDownload
+--- PASS: TestRuntimeAcquisitionNeedsSignedReleaseEntryBeforeDownload (0.02s)
+=== RUN TestRuntimeAcquisitionPassesAuthenticatedHashAndCleansDownload
+--- PASS: TestRuntimeAcquisitionPassesAuthenticatedHashAndCleansDownload (0.00s)
+=== RUN TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting
+=== RUN TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/error
+=== RUN TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/oversize
+=== RUN TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/ok
+--- PASS: TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting (0.07s)
+ --- PASS: TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/error (0.02s)
+ --- PASS: TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/oversize (0.01s)
+ --- PASS: TestRuntimeDownloadRejectsErrorOversizeAndPreservesExisting/ok (0.04s)
+=== RUN TestRuntimeCrashCleanupPreservesPublishedRuntimeAndUserDisk
+--- PASS: TestRuntimeCrashCleanupPreservesPublishedRuntimeAndUserDisk (0.01s)
+=== RUN TestRuntimeDownloadCancellationRemovesPartial
+--- PASS: TestRuntimeDownloadCancellationRemovesPartial (0.01s)
+=== RUN TestVMFreeSpaceGuardObservesDropAndJoins
+--- PASS: TestVMFreeSpaceGuardObservesDropAndJoins (0.00s)
+=== RUN TestVMFreeSpaceGuardStopsWithParent
+--- PASS: TestVMFreeSpaceGuardStopsWithParent (0.00s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/native-capacity-parser-tests.log b/docs/experiments/evidence/2026-09-26-managed-vm/native-capacity-parser-tests.log
new file mode 100644
index 00000000..599cdd02
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/native-capacity-parser-tests.log
@@ -0,0 +1,46 @@
+Verified SHA256 1ada1c8f4638760065f9d34d5858816647b82a74c3e079fee0d16e35656c8bf1
+=== RUN TestVMStorageMinimumsCompatibility
+=== RUN TestVMStorageMinimumsCompatibility/legacy
+=== RUN TestVMStorageMinimumsCompatibility/pair
+=== RUN TestVMStorageMinimumsCompatibility/pair-with-conservative-legacy
+--- PASS: TestVMStorageMinimumsCompatibility (0.00s)
+ --- PASS: TestVMStorageMinimumsCompatibility/legacy (0.00s)
+ --- PASS: TestVMStorageMinimumsCompatibility/pair (0.00s)
+ --- PASS: TestVMStorageMinimumsCompatibility/pair-with-conservative-legacy (0.00s)
+=== RUN TestVMStorageMinimumsRejectMalformedContracts
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"other":1
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":0
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":null
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":34359738368,"minimumTargetDiskBytes":null,"minimumScratchDiskBytes":null
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumScratchDiskBytes":17179869184
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":0
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":null
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":17179869184,"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":17179869184
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":-1
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1.5
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":18446744073709551616
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":"34359738368"
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1,"minimumDiskBytes":34359738368
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1,"MINIMUMDISKBYTES":34359738368
+=== RUN TestVMStorageMinimumsRejectMalformedContracts/"protocolVersion":2,"minimumDiskBytes":34359738368
+--- PASS: TestVMStorageMinimumsRejectMalformedContracts (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"other":1 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":0 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":null (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":34359738368,"minimumTargetDiskBytes":null,"minimumScratchDiskBytes":null (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumScratchDiskBytes":17179869184 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":0 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":null (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":17179869184,"minimumTargetDiskBytes":34359738368,"minimumScratchDiskBytes":17179869184 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":-1 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1.5 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":18446744073709551616 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":"34359738368" (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1,"minimumDiskBytes":34359738368 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"minimumDiskBytes":1,"MINIMUMDISKBYTES":34359738368 (0.00s)
+ --- PASS: TestVMStorageMinimumsRejectMalformedContracts/"protocolVersion":2,"minimumDiskBytes":34359738368 (0.00s)
+=== RUN TestVMStorageAdmissionBoundaries
+--- PASS: TestVMStorageAdmissionBoundaries (0.00s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/native-probe-tests.log b/docs/experiments/evidence/2026-09-26-managed-vm/native-probe-tests.log
new file mode 100644
index 00000000..d250d319
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/native-probe-tests.log
@@ -0,0 +1,67 @@
+Verified SHA256 f19722d8de42b8e381bb72e5f062d0ce290d0f82c2b0e287452eb1ee6f238df7
+=== RUN TestVMAccountInputIsPrivateTransientAndPreservesDisk
+--- PASS: TestVMAccountInputIsPrivateTransientAndPreservesDisk (0.02s)
+=== RUN TestVMAccountValidationDoesNotEchoPassword
+--- PASS: TestVMAccountValidationDoesNotEchoPassword (0.00s)
+=== RUN TestVMReceiptRequiresCreatedAccountIdentity
+--- PASS: TestVMReceiptRequiresCreatedAccountIdentity (0.01s)
+=== RUN TestVMOwnedJobReapsChildOnClose
+--- PASS: TestVMOwnedJobReapsChildOnClose (0.17s)
+=== RUN TestVMEnvironmentDoesNotInheritUserPluginPaths
+--- PASS: TestVMEnvironmentDoesNotInheritUserPluginPaths (0.00s)
+=== RUN TestVMReceiptRejectsProxySuccess
+=== RUN TestVMReceiptRejectsProxySuccess/good
+=== RUN TestVMReceiptRejectsProxySuccess/only-marker
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-run
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-image
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-disk
+=== RUN TestVMReceiptRejectsProxySuccess/no-efi
+=== RUN TestVMReceiptRejectsProxySuccess/duplicate
+=== RUN TestVMReceiptRejectsProxySuccess/late-error
+=== RUN TestVMReceiptRejectsProxySuccess/early-marker
+--- PASS: TestVMReceiptRejectsProxySuccess (0.01s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/good (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/only-marker (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-run (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-image (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-disk (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/no-efi (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/duplicate (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/late-error (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/early-marker (0.00s)
+=== RUN TestVMDurableStateAndNativeWriterGuard
+--- PASS: TestVMDurableStateAndNativeWriterGuard (0.04s)
+=== RUN TestVMExecutionProbeChild
+--- PASS: TestVMExecutionProbeChild (0.00s)
+=== RUN TestVMExecutionProbeNeedsActualSerial
+=== RUN TestVMExecutionProbeNeedsActualSerial/guest
+=== RUN TestVMExecutionProbeNeedsActualSerial/stdout
+=== RUN TestVMExecutionProbeNeedsActualSerial/hung
+=== RUN TestVMExecutionProbeNeedsActualSerial/exit
+--- PASS: TestVMExecutionProbeNeedsActualSerial (2.45s)
+ --- PASS: TestVMExecutionProbeNeedsActualSerial/guest (0.38s)
+ --- PASS: TestVMExecutionProbeNeedsActualSerial/stdout (1.01s)
+ --- PASS: TestVMExecutionProbeNeedsActualSerial/hung (1.01s)
+ --- PASS: TestVMExecutionProbeNeedsActualSerial/exit (0.04s)
+=== RUN TestVMExecutionProbeRefusesStaleLog
+--- PASS: TestVMExecutionProbeRefusesStaleLog (0.00s)
+=== RUN TestVMProbeBootSectorReproducible
+--- PASS: TestVMProbeBootSectorReproducible (0.00s)
+=== RUN TestVMExecutionProbeRealQEMU
+ vm_probe_windows_test.go:38: Actual Windows QEMU q35/TCG execution: passed in 1.1519078s
+--- PASS: TestVMExecutionProbeRealQEMU (3.87s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (0.60s)
+ --- PASS: TestManagedVMShutdownContract/clean (0.10s)
+ --- PASS: TestManagedVMShutdownContract/host (0.08s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.08s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.33s)
+=== RUN TestQMPRejectsMissingGreetingPromptly
+--- PASS: TestQMPRejectsMissingGreetingPromptly (0.00s)
+=== RUN TestVMRuntimeRequiresCompleteSignedClosure
+--- PASS: TestVMRuntimeRequiresCompleteSignedClosure (0.04s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-managed-vm/native-tests.log b/docs/experiments/evidence/2026-09-26-managed-vm/native-tests.log
new file mode 100644
index 00000000..efcdf875
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-managed-vm/native-tests.log
@@ -0,0 +1,40 @@
+Verified SHA256 41673f179fc4e392b56bd759c8b983e6a7d1ff2f1c6f0daea465e5f056410695
+=== RUN TestVMOwnedJobReapsChildOnClose
+--- PASS: TestVMOwnedJobReapsChildOnClose (0.05s)
+=== RUN TestVMEnvironmentDoesNotInheritUserPluginPaths
+--- PASS: TestVMEnvironmentDoesNotInheritUserPluginPaths (0.00s)
+=== RUN TestVMReceiptRejectsProxySuccess
+=== RUN TestVMReceiptRejectsProxySuccess/good
+=== RUN TestVMReceiptRejectsProxySuccess/only-marker
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-run
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-image
+=== RUN TestVMReceiptRejectsProxySuccess/wrong-disk
+=== RUN TestVMReceiptRejectsProxySuccess/no-efi
+=== RUN TestVMReceiptRejectsProxySuccess/duplicate
+=== RUN TestVMReceiptRejectsProxySuccess/late-error
+=== RUN TestVMReceiptRejectsProxySuccess/early-marker
+--- PASS: TestVMReceiptRejectsProxySuccess (0.01s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/good (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/only-marker (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-run (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-image (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/wrong-disk (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/no-efi (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/duplicate (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/late-error (0.00s)
+ --- PASS: TestVMReceiptRejectsProxySuccess/early-marker (0.00s)
+=== RUN TestVMDurableStateAndNativeWriterGuard
+--- PASS: TestVMDurableStateAndNativeWriterGuard (0.04s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (0.84s)
+ --- PASS: TestManagedVMShutdownContract/clean (0.21s)
+ --- PASS: TestManagedVMShutdownContract/host (0.16s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.16s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.31s)
+=== RUN TestVMRuntimeRequiresCompleteSignedClosure
+--- PASS: TestVMRuntimeRequiresCompleteSignedClosure (0.05s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/bundle.json b/docs/experiments/evidence/2026-09-26-vm-runtime/bundle.json
new file mode 100644
index 00000000..a85c3e49
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/bundle.json
@@ -0,0 +1,12 @@
+{
+ "bytes": 183295958,
+ "sha256": "7b40edf55a7686cd6e59088432717e3c5293b2378d115c671d922da7c97e869b",
+ "runtime_bytes": 321059722,
+ "manifest_files": 3282,
+ "manifest_bytes": 398147,
+ "helper_commit": "4f9985d",
+ "helper_account_outcome": "image-default",
+ "public_key": "def3840b56c1553b4a4de45f995fa007e30f9ff5763007e142ab924fbaefe661",
+ "scope": "Experimental local packaging. No runtime release or full Linux desktop verdict.",
+ "installer_test_exe_sha256": "2e17329d4c61503268a555deba69b131ccc83e9965ddb639af9aa36490c6b2fc"
+}
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-interactive-probe.ps1 b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-interactive-probe.ps1
new file mode 100644
index 00000000..7c57c756
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-interactive-probe.ps1
@@ -0,0 +1,36 @@
+Start-Transcript -Path 'C:\Windows\Temp\wootc-runtime-bundle-probe\gtk-interactive-transcript.log' -Force | Out-Null
+$ErrorActionPreference = 'Stop'
+$dir = 'C:\Windows\Temp\wootc-runtime-bundle-probe'
+$qemu = "$dir\qemu\qemu-system-x86_64.exe"
+$env:PATH = "$dir\qemu;C:\Windows\System32;C:\Windows"
+$env:TEMP = $dir
+$env:TMP = $dir
+$env:HOME = $dir
+foreach ($name in @('GTK_PATH','GTK_EXE_PREFIX','GTK_DATA_PREFIX','GIO_EXTRA_MODULES','GIO_MODULE_DIR','GDK_PIXBUF_MODULE_FILE','GDK_PIXBUF_MODULEDIR','QEMU_AUDIO_DRV','QEMU_MODULE_DIR','QEMU_BIOS_DIR','XDG_CONFIG_HOME','XDG_DATA_DIRS')) {
+ Remove-Item "Env:$name" -ErrorAction SilentlyContinue
+}
+$serial = "$dir\gtk-interactive-serial.log"
+$arguments = @('-machine','pc','-accel','tcg','-smp','1','-m','128','-display','gtk,window-close=off,show-menubar=off','-monitor','none','-serial',"file:$serial",'-drive',"file=$dir\serial-boot.img,format=raw,if=floppy",'-boot','a')
+$timer = [Diagnostics.Stopwatch]::StartNew()
+$p = Start-Process $qemu -WorkingDirectory $dir -ArgumentList $arguments -RedirectStandardOutput "$dir\gtk-interactive-out.log" -RedirectStandardError "$dir\gtk-interactive-err.log" -PassThru
+$ready = $false
+try {
+ while (-not $p.HasExited -and $timer.Elapsed.TotalSeconds -lt 30) {
+ if ((Test-Path $serial) -and (Select-String -Path $serial -SimpleMatch WOOTC_WHPX_BOOTSECTOR_EXECUTED -Quiet)) { $ready = $true; break }
+ Start-Sleep -Milliseconds 100
+ $p.Refresh()
+ }
+ Write-Output "BUNDLE_GTK_BOOT=$ready SECONDS=$($timer.Elapsed.TotalSeconds) SESSION=$($p.SessionId)"
+ if (-not $ready) { throw 'Bundled GTK runtime did not execute guest marker' }
+ foreach ($module in $p.Modules) {
+ $path = $module.FileName
+ if (-not $path.StartsWith("$dir\qemu\", [StringComparison]::OrdinalIgnoreCase) -and -not $path.StartsWith('C:\Windows\System32\', [StringComparison]::OrdinalIgnoreCase) -and -not $path.StartsWith('C:\Windows\WinSxS\', [StringComparison]::OrdinalIgnoreCase)) { throw "Module loaded outside runtime or Windows: $path" }
+ if ($path.StartsWith("$dir\qemu\", [StringComparison]::OrdinalIgnoreCase)) { Write-Output "BUNDLE_MODULE=$($module.ModuleName)" }
+ }
+} finally {
+ if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force }
+ Get-Content "$dir\gtk-interactive-err.log" -ErrorAction SilentlyContinue
+ Get-Content $serial -ErrorAction SilentlyContinue
+}
+
+Stop-Transcript | Out-Null
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-session0-probe.ps1 b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-session0-probe.ps1
new file mode 100644
index 00000000..b9c379a9
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-session0-probe.ps1
@@ -0,0 +1,33 @@
+$ErrorActionPreference = 'Stop'
+$dir = 'C:\Windows\Temp\wootc-runtime-bundle-probe'
+$qemu = "$dir\qemu\qemu-system-x86_64.exe"
+$env:PATH = "$dir\qemu;C:\Windows\System32;C:\Windows"
+$env:TEMP = $dir
+$env:TMP = $dir
+$env:HOME = $dir
+foreach ($name in @('GTK_PATH','GTK_EXE_PREFIX','GTK_DATA_PREFIX','GIO_EXTRA_MODULES','GIO_MODULE_DIR','GDK_PIXBUF_MODULE_FILE','GDK_PIXBUF_MODULEDIR','QEMU_AUDIO_DRV','QEMU_MODULE_DIR','QEMU_BIOS_DIR','XDG_CONFIG_HOME','XDG_DATA_DIRS')) {
+ Remove-Item "Env:$name" -ErrorAction SilentlyContinue
+}
+$serial = "$dir\gtk-serial.log"
+$arguments = @('-machine','pc','-accel','tcg','-smp','1','-m','128','-display','gtk,window-close=off,show-menubar=off','-monitor','none','-serial',"file:$serial",'-drive',"file=$dir\serial-boot.img,format=raw,if=floppy",'-boot','a')
+$timer = [Diagnostics.Stopwatch]::StartNew()
+$p = Start-Process $qemu -WorkingDirectory $dir -ArgumentList $arguments -RedirectStandardOutput "$dir\gtk-out.log" -RedirectStandardError "$dir\gtk-err.log" -PassThru
+$ready = $false
+try {
+ while (-not $p.HasExited -and $timer.Elapsed.TotalSeconds -lt 30) {
+ if ((Test-Path $serial) -and (Select-String -Path $serial -SimpleMatch WOOTC_WHPX_BOOTSECTOR_EXECUTED -Quiet)) { $ready = $true; break }
+ Start-Sleep -Milliseconds 100
+ $p.Refresh()
+ }
+ Write-Output "BUNDLE_GTK_BOOT=$ready SECONDS=$($timer.Elapsed.TotalSeconds) SESSION=$($p.SessionId)"
+ if (-not $ready) { throw 'Bundled GTK runtime did not execute guest marker' }
+ foreach ($module in $p.Modules) {
+ $path = $module.FileName
+ if (-not $path.StartsWith("$dir\qemu\", [StringComparison]::OrdinalIgnoreCase) -and -not $path.StartsWith('C:\Windows\System32\', [StringComparison]::OrdinalIgnoreCase) -and -not $path.StartsWith('C:\Windows\WinSxS\', [StringComparison]::OrdinalIgnoreCase)) { throw "Module loaded outside runtime or Windows: $path" }
+ if ($path.StartsWith("$dir\qemu\", [StringComparison]::OrdinalIgnoreCase)) { Write-Output "BUNDLE_MODULE=$($module.ModuleName)" }
+ }
+} finally {
+ if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force }
+ Get-Content "$dir\gtk-err.log" -ErrorAction SilentlyContinue
+ Get-Content $serial -ErrorAction SilentlyContinue
+}
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-task.ps1 b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-task.ps1
new file mode 100644
index 00000000..93b4a639
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/gtk-task.ps1
@@ -0,0 +1,10 @@
+$ErrorActionPreference = 'Stop'
+$user = (Get-CimInstance Win32_ComputerSystem).UserName
+if (-not $user) { throw 'No interactive Windows user' }
+$taskName = 'wootc-runtime-gtk-probe-20260926'
+if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { throw 'Existing task; preserve previous run' }
+$action = New-ScheduledTaskAction -Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -Argument '-NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Temp\wootc-runtime-bundle-probe\gtk-interactive.ps1'
+$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
+Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal | Out-Null
+Start-ScheduledTask -TaskName $taskName
+Write-Output "STARTED_INTERACTIVE_USER=$user TASK=$taskName"
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/runtime-probe.ps1 b/docs/experiments/evidence/2026-09-26-vm-runtime/runtime-probe.ps1
new file mode 100644
index 00000000..f26dda53
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/runtime-probe.ps1
@@ -0,0 +1,41 @@
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$dir = 'C:\Windows\Temp\wootc-runtime-bundle-probe'
+if (Test-Path $dir) { throw 'Probe directory already exists; preserve previous evidence' }
+New-Item -ItemType Directory -Path $dir | Out-Null
+Invoke-WebRequest -UseBasicParsing 'http://10.104.210.10:8080/wootc-vm-runtime.zip' -OutFile "$dir\runtime.zip"
+if ((Get-FileHash -Algorithm SHA256 "$dir\runtime.zip").Hash.ToLowerInvariant() -ne '7b40edf55a7686cd6e59088432717e3c5293b2378d115c671d922da7c97e869b') { throw 'Runtime ZIP hash mismatch' }
+Invoke-WebRequest -UseBasicParsing 'http://10.104.210.10:8080/serial-boot.img' -OutFile "$dir\serial-boot.img"
+if ((Get-FileHash -Algorithm SHA256 "$dir\serial-boot.img").Hash.ToLowerInvariant() -ne 'f591bb62b9fce510dc7a4a096940ffbfc596d925739416559017cfb2a92e7195') { throw 'Bootsector hash mismatch' }
+Expand-Archive -Path "$dir\runtime.zip" -DestinationPath $dir
+$qemu = "$dir\qemu\qemu-system-x86_64.exe"
+$env:PATH = "$dir\qemu;C:\Windows\System32;C:\Windows"
+$env:TEMP = $dir
+$env:TMP = $dir
+$env:HOME = $dir
+foreach ($name in @('GTK_PATH','GTK_EXE_PREFIX','GTK_DATA_PREFIX','GIO_EXTRA_MODULES','GIO_MODULE_DIR','GDK_PIXBUF_MODULE_FILE','GDK_PIXBUF_MODULEDIR','QEMU_AUDIO_DRV','QEMU_MODULE_DIR','QEMU_BIOS_DIR','XDG_CONFIG_HOME','XDG_DATA_DIRS')) {
+ Remove-Item "Env:$name" -ErrorAction SilentlyContinue
+}
+Set-Location $dir
+& $qemu '--version'
+if ($LASTEXITCODE -ne 0) { throw 'Bundled QEMU cannot load' }
+& $qemu '-display' 'help'
+if ($LASTEXITCODE -ne 0) { throw 'Bundled QEMU display help failed' }
+$serial = "$dir\serial.log"
+$arguments = @('-machine','pc','-accel','tcg','-smp','1','-m','128','-display','none','-monitor','none','-serial',"file:$serial",'-drive',"file=$dir\serial-boot.img,format=raw,if=floppy",'-boot','a')
+$timer = [Diagnostics.Stopwatch]::StartNew()
+$p = Start-Process $qemu -WorkingDirectory $dir -ArgumentList $arguments -RedirectStandardOutput "$dir\out.log" -RedirectStandardError "$dir\err.log" -PassThru
+$ready = $false
+try {
+ while (-not $p.HasExited -and $timer.Elapsed.TotalSeconds -lt 30) {
+ if ((Test-Path $serial) -and (Select-String -Path $serial -SimpleMatch WOOTC_WHPX_BOOTSECTOR_EXECUTED -Quiet)) { $ready = $true; break }
+ Start-Sleep -Milliseconds 100
+ $p.Refresh()
+ }
+ Write-Output "BUNDLE_TCG_BOOT=$ready SECONDS=$($timer.Elapsed.TotalSeconds)"
+ if (-not $ready) { throw 'Bundled runtime did not execute the guest marker' }
+} finally {
+ if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force }
+ Get-Content "$dir\err.log" -ErrorAction SilentlyContinue
+ Get-Content $serial -ErrorAction SilentlyContinue
+}
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-interactive.log b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-interactive.log
new file mode 100644
index 00000000..a8c02190
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-interactive.log
@@ -0,0 +1,136 @@
+
+TaskName State
+-------- -----
+wootc-runtime-gtk-probe-20260926 Ready
+
+**********************
+Windows PowerShell transcript start
+Start time: 20260926183129
+Username: WOOTC-TEST\wootc
+RunAs User: WOOTC-TEST\wootc
+Configuration Name:
+Machine: WOOTC-TEST (Microsoft Windows NT 10.0.26200.0)
+Host Application: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Temp\wootc-runtime-bundle-probe\gtk-interactive.ps1
+Process ID: 10092
+PSVersion: 5.1.26100.7920
+PSEdition: Desktop
+PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.7920
+BuildVersion: 10.0.26100.7920
+CLRVersion: 4.0.30319.42000
+WSManStackVersion: 3.0
+PSRemotingProtocolVersion: 2.3
+SerializationVersion: 1.1.0.1
+**********************
+BUNDLE_GTK_BOOT=True SECONDS=2.5399571 SESSION=1
+BUNDLE_MODULE=qemu-system-x86_64.exe
+BUNDLE_MODULE=SDL2.dll
+BUNDLE_MODULE=libbz2-1.dll
+BUNDLE_MODULE=brlapi-0.8.dll
+BUNDLE_MODULE=SDL2_image.dll
+BUNDLE_MODULE=libcacard-0.dll
+BUNDLE_MODULE=libcapstone.dll
+BUNDLE_MODULE=libcurl-4.dll
+BUNDLE_MODULE=libepoxy-0.dll
+BUNDLE_MODULE=libfdt-1.dll
+BUNDLE_MODULE=libgdk-3-0.dll
+BUNDLE_MODULE=libgio-2.0-0.dll
+BUNDLE_MODULE=libglib-2.0-0.dll
+BUNDLE_MODULE=libgmodule-2.0-0.dll
+BUNDLE_MODULE=libgobject-2.0-0.dll
+BUNDLE_MODULE=libgtk-3-0.dll
+BUNDLE_MODULE=libgnutls-30.dll
+BUNDLE_MODULE=libintl-8.dll
+BUNDLE_MODULE=libjack64.dll
+BUNDLE_MODULE=libjpeg-8.dll
+BUNDLE_MODULE=libiconv-2.dll
+BUNDLE_MODULE=liblzo2-2.dll
+BUNDLE_MODULE=libncursesw6.dll
+BUNDLE_MODULE=libnfs-16.dll
+BUNDLE_MODULE=libpixman-1-0.dll
+BUNDLE_MODULE=libsasl2-3.dll
+BUNDLE_MODULE=libpng16-16.dll
+BUNDLE_MODULE=libslirp-0.dll
+BUNDLE_MODULE=libsnappy.dll
+BUNDLE_MODULE=libssh.dll
+BUNDLE_MODULE=libu2f-emu-0.dll
+BUNDLE_MODULE=libusb-1.0.dll
+BUNDLE_MODULE=libgdk_pixbuf-2.0-0.dll
+BUNDLE_MODULE=libusbredirparser-1.dll
+BUNDLE_MODULE=libcairo-2.dll
+BUNDLE_MODULE=libspice-server-1.dll
+BUNDLE_MODULE=libvirglrenderer-1.dll
+BUNDLE_MODULE=zlib1.dll
+BUNDLE_MODULE=libwinpthread-1.dll
+BUNDLE_MODULE=libzstd.dll
+BUNDLE_MODULE=libssp-0.dll
+BUNDLE_MODULE=libjxl.dll
+BUNDLE_MODULE=libtiff-6.dll
+BUNDLE_MODULE=libwebp-7.dll
+BUNDLE_MODULE=libwebpdemux-2.dll
+BUNDLE_MODULE=nss3.dll
+BUNDLE_MODULE=libnspr4.dll
+BUNDLE_MODULE=libidn2-0.dll
+BUNDLE_MODULE=libbrotlidec.dll
+BUNDLE_MODULE=libssh2-1.dll
+BUNDLE_MODULE=libpsl-5.dll
+BUNDLE_MODULE=libavif-16.dll
+BUNDLE_MODULE=libcairo-gobject-2.dll
+BUNDLE_MODULE=libpango-1.0-0.dll
+BUNDLE_MODULE=libfribidi-0.dll
+BUNDLE_MODULE=libpangowin32-1.0-0.dll
+BUNDLE_MODULE=libpcre2-8-0.dll
+BUNDLE_MODULE=libpangocairo-1.0-0.dll
+BUNDLE_MODULE=libbrotlienc.dll
+BUNDLE_MODULE=libffi-8.dll
+BUNDLE_MODULE=libgmp-10.dll
+BUNDLE_MODULE=libhogweed-7.dll
+BUNDLE_MODULE=libp11-kit-0.dll
+BUNDLE_MODULE=libnettle-9.dll
+BUNDLE_MODULE=libtasn1-6.dll
+BUNDLE_MODULE=libunistring-5.dll
+BUNDLE_MODULE=libatk-1.0-0.dll
+BUNDLE_MODULE=libgcc_s_seh-1.dll
+BUNDLE_MODULE=libdb-6.2.dll
+BUNDLE_MODULE=libsystre-0.dll
+BUNDLE_MODULE=libstdc++-6.dll
+BUNDLE_MODULE=libharfbuzz-0.dll
+BUNDLE_MODULE=libcrypto-3-x64.dll
+BUNDLE_MODULE=libgstapp-1.0-0.dll
+BUNDLE_MODULE=liblz4.dll
+BUNDLE_MODULE=libopus-0.dll
+BUNDLE_MODULE=liborc-0.4-0.dll
+BUNDLE_MODULE=libgstreamer-1.0-0.dll
+BUNDLE_MODULE=libssl-3-x64.dll
+BUNDLE_MODULE=libfreetype-6.dll
+BUNDLE_MODULE=libfontconfig-1.dll
+BUNDLE_MODULE=libhwy.dll
+BUNDLE_MODULE=libjxl_cms.dll
+BUNDLE_MODULE=libdeflate.dll
+BUNDLE_MODULE=libLerc.dll
+BUNDLE_MODULE=libjbig-0.dll
+BUNDLE_MODULE=liblzma-5.dll
+BUNDLE_MODULE=nssutil3.dll
+BUNDLE_MODULE=libsharpyuv-0.dll
+BUNDLE_MODULE=libplds4.dll
+BUNDLE_MODULE=libbrotlicommon.dll
+BUNDLE_MODULE=libdav1d-7.dll
+BUNDLE_MODULE=librav1e.dll
+BUNDLE_MODULE=libaom.dll
+BUNDLE_MODULE=libyuv.dll
+BUNDLE_MODULE=libSvtAv1Enc-4.dll
+BUNDLE_MODULE=libplc4.dll
+BUNDLE_MODULE=libthai-0.dll
+BUNDLE_MODULE=libpangoft2-1.0-0.dll
+BUNDLE_MODULE=libtre-5.dll
+BUNDLE_MODULE=libgraphite2.dll
+BUNDLE_MODULE=libgstbase-1.0-0.dll
+BUNDLE_MODULE=libexpat-1.dll
+BUNDLE_MODULE=liblcms2-2.dll
+BUNDLE_MODULE=libdatrie-1.dll
+WOOTC_WHPX_BOOTSECTOR_EXECUTED
+**********************
+Windows PowerShell transcript end
+End time: 20260926183133
+**********************
+
+
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-session0.log b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-session0.log
new file mode 100644
index 00000000..e1a84031
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-gtk-session0.log
@@ -0,0 +1,82 @@
+BUNDLE_GTK_BOOT=False SECONDS=30.0479868 SESSION=0
+C:\Windows\Temp\wootc-runtime-bundle-probe\qemu\qemu-system-x86_64.exe: Gdk: gdk_monitor_get_geometry: assertion 'GDK_IS_MONITOR (monitor)' failed
+C:\Windows\Temp\wootc-runtime-bundle-probe\qemu\qemu-system-x86_64.exe: Gdk: _gdk_win32_monitor_get_pixel_structure: assertion 'monitor != NULL' failed
+
+(qemu:12928): Gdk-WARNING **: 18:29:42.895: ../gtk-3.24.52/gdk/win32/gdkwindow-win32.c:527: DwmEnableBlurBehindWindow (0000000000250074) failed: 80263001
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gdk_monitor_get_refresh_rate: assertion 'GDK_IS_MONITOR (monitor)' failed
+qemu: Gdk: gd
+Bundled GTK runtime did not execute guest marker
+At line:23 char:22
++ ... t $ready) { throw 'Bundled GTK runtime did not execute guest marker' ...
++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ + CategoryInfo : OperationStopped: (Bundled GTK run...te guest marker:String) [], RuntimeException
+ + FullyQualifiedErrorId : Bundled GTK runtime did not execute guest marker
+
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.log b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.log
new file mode 100644
index 00000000..c6716b21
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.log
@@ -0,0 +1,48 @@
+=== RUN TestInstallAndPreserveExisting
+--- PASS: TestInstallAndPreserveExisting (0.13s)
+=== RUN TestRejectArchiveAndPreserveParent
+=== RUN TestRejectArchiveAndPreserveParent/absolute
+=== RUN TestRejectArchiveAndPreserveParent/alternate_stream
+=== RUN TestRejectArchiveAndPreserveParent/backslash
+=== RUN TestRejectArchiveAndPreserveParent/device
+=== RUN TestRejectArchiveAndPreserveParent/directory_case
+=== RUN TestRejectArchiveAndPreserveParent/drive
+=== RUN TestRejectArchiveAndPreserveParent/duplicate
+=== RUN TestRejectArchiveAndPreserveParent/edited_payload
+=== RUN TestRejectArchiveAndPreserveParent/extra_DLL
+=== RUN TestRejectArchiveAndPreserveParent/file_case
+=== RUN TestRejectArchiveAndPreserveParent/missing_DLL
+=== RUN TestRejectArchiveAndPreserveParent/symlink
+=== RUN TestRejectArchiveAndPreserveParent/trailing_dot
+=== RUN TestRejectArchiveAndPreserveParent/traversal
+=== RUN TestRejectArchiveAndPreserveParent/unmanifested_required
+--- PASS: TestRejectArchiveAndPreserveParent (0.36s)
+ --- PASS: TestRejectArchiveAndPreserveParent/absolute (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/alternate_stream (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/backslash (0.03s)
+ --- PASS: TestRejectArchiveAndPreserveParent/device (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/directory_case (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/drive (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/duplicate (0.03s)
+ --- PASS: TestRejectArchiveAndPreserveParent/edited_payload (0.03s)
+ --- PASS: TestRejectArchiveAndPreserveParent/extra_DLL (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/file_case (0.05s)
+ --- PASS: TestRejectArchiveAndPreserveParent/missing_DLL (0.03s)
+ --- PASS: TestRejectArchiveAndPreserveParent/symlink (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/trailing_dot (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/traversal (0.02s)
+ --- PASS: TestRejectArchiveAndPreserveParent/unmanifested_required (0.02s)
+=== RUN TestOuterHashSignatureAndCancellation
+=== RUN TestOuterHashSignatureAndCancellation/outer_hash
+=== RUN TestOuterHashSignatureAndCancellation/signature
+=== RUN TestOuterHashSignatureAndCancellation/cancel
+--- PASS: TestOuterHashSignatureAndCancellation (0.03s)
+ --- PASS: TestOuterHashSignatureAndCancellation/outer_hash (0.02s)
+ --- PASS: TestOuterHashSignatureAndCancellation/signature (0.00s)
+ --- PASS: TestOuterHashSignatureAndCancellation/cancel (0.00s)
+=== RUN TestRejectExpansionBounds
+--- PASS: TestRejectExpansionBounds (0.00s)
+=== RUN TestRealArchive
+ install_test.go:205: authenticated and installed 3284 runtime files
+--- PASS: TestRealArchive (37.84s)
+PASS
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.ps1 b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.ps1
new file mode 100644
index 00000000..e2eddae0
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.ps1
@@ -0,0 +1,9 @@
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+$test = 'C:\Windows\Temp\wootc-runtime-install.test.exe'
+Invoke-WebRequest -UseBasicParsing 'http://10.104.210.10:8080/wootc-runtime-install.test.exe' -OutFile $test
+if ((Get-FileHash -Algorithm SHA256 $test).Hash.ToLowerInvariant() -ne '2e17329d4c61503268a555deba69b131ccc83e9965ddb639af9aa36490c6b2fc') { throw 'Native test binary hash mismatch' }
+$env:WOOTC_TEST_RUNTIME_ARCHIVE = 'C:\Windows\Temp\wootc-runtime-bundle-probe\runtime.zip'
+$env:WOOTC_TEST_RUNTIME_PUBLIC_KEY = 'def3840b56c1553b4a4de45f995fa007e30f9ff5763007e142ab924fbaefe661'
+& $test '-test.v'
+exit $LASTEXITCODE
diff --git a/docs/experiments/evidence/2026-09-26-vm-runtime/windows-tcg.log b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-tcg.log
new file mode 100644
index 00000000..332130c2
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-26-vm-runtime/windows-tcg.log
@@ -0,0 +1,16 @@
+QEMU emulator version 11.1.0 (v11.1.0-12130-ge470268ff4)
+Copyright (c) 2003-2026 Fabrice Bellard and the QEMU Project developers
+Available display backend types:
+none
+gtk
+sdl
+egl-headless
+curses
+spice-app
+dbus
+
+Some display backends support suboptions, which can be set with
+ -display backend,option=value,option=value...
+For a short list of the suboptions for each display, see the top-level -help output; more detail is in the documentation.
+BUNDLE_TCG_BOOT=True SECONDS=0.5450885
+WOOTC_WHPX_BOOTSECTOR_EXECUTED
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/.gitattributes b/docs/experiments/evidence/2026-09-27-scoped-qmp/.gitattributes
new file mode 100644
index 00000000..817a7482
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/.gitattributes
@@ -0,0 +1 @@
+*.log -text
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/actionlint.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/actionlint.log
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/actual-retained-bytes-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/actual-retained-bytes-mutant.log
new file mode 100644
index 00000000..97044d24
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/actual-retained-bytes-mutant.log
@@ -0,0 +1,6 @@
+=== RUN TestVMDisplayActualRetainedByteBudget
+ vm_display_test.go:403: actual file bytes not measured 1 0
+--- FAIL: TestVMDisplayActualRetainedByteBudget (0.02s)
+FAIL
+FAIL wootc 0.028s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-budget-before-qmp-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-budget-before-qmp-mutant.log
new file mode 100644
index 00000000..eaaf790c
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-budget-before-qmp-mutant.log
@@ -0,0 +1,6 @@
+=== RUN TestVMDisplayCaptureRetentionExhaustion
+ vm_display_test.go:347: exhausted budget reached QMP
+--- FAIL: TestVMDisplayCaptureRetentionExhaustion (0.05s)
+FAIL
+FAIL wootc 0.059s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-pixels-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-pixels-mutant.log
new file mode 100644
index 00000000..500e0ffc
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/capture-pixels-mutant.log
@@ -0,0 +1,18 @@
+=== RUN TestVMDisplayUnknownOrFailedObservations
+=== RUN TestVMDisplayUnknownOrFailedObservations/paused
+=== RUN TestVMDisplayUnknownOrFailedObservations/fake-status
+=== RUN TestVMDisplayUnknownOrFailedObservations/ambiguous-mice
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-pixels
+ vm_display_test.go:197: failed observation accepted
+=== RUN TestVMDisplayUnknownOrFailedObservations/missing-capture
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-key-ack
+--- FAIL: TestVMDisplayUnknownOrFailedObservations (0.19s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/paused (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/fake-status (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/ambiguous-mice (0.03s)
+ --- FAIL: TestVMDisplayUnknownOrFailedObservations/bad-pixels (0.02s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/missing-capture (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-key-ack (0.03s)
+FAIL
+FAIL wootc 0.203s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/current-missing-return.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/current-missing-return.raw.log
new file mode 100644
index 00000000..788ee3f4
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/current-missing-return.raw.log
@@ -0,0 +1,4 @@
+=== RUN TestMissingCapabilityReturnMustRefuse
+--- PASS: TestMissingCapabilityReturnMustRefuse (0.00s)
+PASS
+ok command-line-arguments 0.010s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/engine-owner-after-response-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/engine-owner-after-response-mutant.log
new file mode 100644
index 00000000..4b232c01
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/engine-owner-after-response-mutant.log
@@ -0,0 +1,6 @@
+=== RUN TestVMDisplayEngineOwnerAndChangedSession
+ vm_display_test.go:281: changed engine owner accepted stale session response
+--- FAIL: TestVMDisplayEngineOwnerAndChangedSession (0.20s)
+FAIL
+FAIL wootc 0.219s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go-b6.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go-b6.raw.log
new file mode 100644
index 00000000..d7a5f392
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go-b6.raw.log
@@ -0,0 +1,5 @@
+ok wootc 6.900s
+ok wootc/internal/artifactauth 0.038s
+ok wootc/internal/runtimebundle 0.106s
+ok wootc/tools/gendto 0.010s
+ok wootc/tools/signmanifest 0.011s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go21dd.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go21dd.raw.log
new file mode 100644
index 00000000..28612d36
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/full-go21dd.raw.log
@@ -0,0 +1,5 @@
+ok wootc 9.589s
+ok wootc/internal/artifactauth 0.063s
+ok wootc/internal/runtimebundle 0.175s
+ok wootc/tools/gendto 0.020s
+ok wootc/tools/signmanifest 0.022s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/guard-mutations.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/guard-mutations.json
new file mode 100644
index 00000000..aac62bba
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/guard-mutations.json
@@ -0,0 +1,107 @@
+[
+ {
+ "case": "missing-return",
+ "exit": 1,
+ "sourceFile": "app/vm_qmp.go",
+ "productionSha256": "1a434e11459306dafc415eac323e98f4c3f5d8a2b1ee94bfb9568dcdfe12e66f",
+ "mutantSha256": "47f313ce817dd1ca7ba3f61903408d1572a986e9ce1d7acc888994ad909beb9e",
+ "behaviorFailureObserved": "invalid response accepted",
+ "test": "TestQMPTypedResponseCounterexamples",
+ "replacements": [
+ [
+ "hasReturn == hasError || len(fields) != 2",
+ "(hasReturn && hasError)"
+ ]
+ ]
+ },
+ {
+ "case": "session-correlation",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "745b1b87227e49777fdbf31bfbd96d79b63bd3a552e8b8c0eafecfd6fe6650d6",
+ "behaviorFailureObserved": "accepted",
+ "test": "TestVMDisplayIdentityAndReplayRefuseBeforeGuest",
+ "replacements": [
+ [
+ "req.SessionID != s.sessionID",
+ "false"
+ ]
+ ]
+ },
+ {
+ "case": "running-observation",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "81c5f2779dd8d587445894ed0ee482d1040429846912f2d090751f89629e597e",
+ "behaviorFailureObserved": "failed observation accepted",
+ "test": "TestVMDisplayUnknownOrFailedObservations",
+ "replacements": [
+ [
+ " || !*r.Running || r.Status != \"running\"",
+ ""
+ ]
+ ]
+ },
+ {
+ "case": "capture-pixels",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "7e4e71eb74fcc14e9833121558fa6116d5ce48479127ab02d7044a9b8625a34d",
+ "behaviorFailureObserved": "failed observation accepted",
+ "test": "TestVMDisplayUnknownOrFailedObservations",
+ "replacements": [
+ [
+ "n, err := io.Copy(io.Discard, r)\n\tif err != nil || n != int64(width)*int64(height)*3",
+ "_, err = io.Copy(io.Discard, r)\n\tif err != nil"
+ ]
+ ]
+ },
+ {
+ "case": "capture-budget-before-qmp",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "d7209573213e5fc74bffb49b8efdfaf60e5e7d46ecb883160ed47e446fc0a0be",
+ "behaviorFailureObserved": "exhausted budget reached QMP",
+ "test": "TestVMDisplayCaptureRetentionExhaustion",
+ "replacements": [
+ [
+ "\tif req.Action == \"capture\" {\n\t\tif _, count, bytes, err := s.captureInventory(); err != nil {\n\t\t\treturn out, err\n\t\t} else if count >= vmCaptureCountLimit || bytes >= vmCaptureBytesLimit {\n\t\t\treturn out, fmt.Errorf(\"retained capture budget exhausted\")\n\t\t}\n\t}",
+ ""
+ ]
+ ]
+ },
+ {
+ "case": "engine-owner-after-response",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "7609178e920535bc5718ca24f725b830de9e68421cafcf459d11f4d8773548d5",
+ "behaviorFailureObserved": "changed engine owner accepted stale session response",
+ "test": "TestVMDisplayEngineOwnerAndChangedSession",
+ "replacements": [
+ [
+ "if !same {",
+ "if false && !same {"
+ ]
+ ]
+ },
+ {
+ "case": "actual-retained-bytes",
+ "exit": 1,
+ "sourceFile": "app/vm_display.go",
+ "productionSha256": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "mutantSha256": "a7178f6a100e7b7fadf81c144ac7a3de0a0a74775121dca739c425e052246472",
+ "behaviorFailureObserved": "actual file bytes not measured",
+ "test": "TestVMDisplayActualRetainedByteBudget",
+ "replacements": [
+ [
+ "retained += info.Size()",
+ "retained += 0"
+ ]
+ ]
+ }
+]
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-after.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-after.raw.log
new file mode 100644
index 00000000..4feda177
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-after.raw.log
@@ -0,0 +1,4 @@
+=== RUN TestVMDisplayNeverInheritsDesktopClaim
+--- PASS: TestVMDisplayNeverInheritsDesktopClaim (0.02s)
+PASS
+ok wootc 0.029s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-before.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-before.raw.log
new file mode 100644
index 00000000..1fbf6946
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/inherited-desktop-before.raw.log
@@ -0,0 +1,6 @@
+=== RUN TestVMDisplayNeverInheritsDesktopClaim
+ vm_display_test.go:431: incoming state became live desktop proof without guest semantics
+--- FAIL: TestVMDisplayNeverInheritsDesktopClaim (0.02s)
+FAIL
+FAIL wootc 0.021s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/initial-race-fixture-deadline.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/initial-race-fixture-deadline.raw.log
new file mode 100644
index 00000000..c7b535b4
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/initial-race-fixture-deadline.raw.log
@@ -0,0 +1,76 @@
+=== RUN TestVMDisplayChild
+--- PASS: TestVMDisplayChild (0.00s)
+=== RUN TestVMDisplayActionsAndSessionNonce
+--- PASS: TestVMDisplayActionsAndSessionNonce (0.09s)
+=== RUN TestVMDisplayIdentityAndReplayRefuseBeforeGuest
+--- PASS: TestVMDisplayIdentityAndReplayRefuseBeforeGuest (0.04s)
+=== RUN TestVMDisplayUnknownOrFailedObservations
+=== RUN TestVMDisplayUnknownOrFailedObservations/paused
+=== RUN TestVMDisplayUnknownOrFailedObservations/fake-status
+=== RUN TestVMDisplayUnknownOrFailedObservations/ambiguous-mice
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-pixels
+=== RUN TestVMDisplayUnknownOrFailedObservations/missing-capture
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-key-ack
+--- PASS: TestVMDisplayUnknownOrFailedObservations (0.21s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/paused (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/fake-status (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/ambiguous-mice (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-pixels (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/missing-capture (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-key-ack (0.03s)
+=== RUN TestVMDisplayKeyValidation
+--- PASS: TestVMDisplayKeyValidation (0.00s)
+=== RUN TestVMDisplayDeadlineAndLifecycle
+--- PASS: TestVMDisplayDeadlineAndLifecycle (0.08s)
+=== RUN TestVMDisplaySymlinkCaptureParentRefused
+--- PASS: TestVMDisplaySymlinkCaptureParentRefused (0.04s)
+=== RUN TestVMDisplayEngineOwnerAndChangedSession
+--- PASS: TestVMDisplayEngineOwnerAndChangedSession (0.04s)
+=== RUN TestVMDisplayForceRemainsAvailableDuringShutdownWait
+--- PASS: TestVMDisplayForceRemainsAvailableDuringShutdownWait (0.04s)
+=== RUN TestVMDisplayCaptureRetentionExhaustion
+ vm_display_test.go:336: Linux has not completed shutdown; its disk remains locked: context deadline exceeded
+--- FAIL: TestVMDisplayCaptureRetentionExhaustion (1.05s)
+=== RUN TestVMDisplayActualRetainedByteBudget
+--- PASS: TestVMDisplayActualRetainedByteBudget (0.03s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (2.30s)
+ --- PASS: TestManagedVMShutdownContract/clean (1.04s)
+ --- PASS: TestManagedVMShutdownContract/host (1.04s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.04s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.18s)
+=== RUN TestQMPNativePausedComponent
+ vm_qmp_native_test.go:27: QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)
+ Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers
+ vm_qmp_native_test.go:49: actual paused status {"status": "prelaunch", "singlestep": false, "running": false}
+ vm_qmp_native_test.go:61: actual native PPM 640x480 sha256=15e7d086f38e02fb3b8af538b68d76619b0ecf56927333e1ba7510e79346fb03
+--- PASS: TestQMPNativePausedComponent (0.09s)
+=== RUN TestQMPTypedResponseCounterexamples
+=== RUN TestQMPTypedResponseCounterexamples/{"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"}
+=== RUN TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+--- PASS: TestQMPTypedResponseCounterexamples (0.01s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+=== RUN TestQMPBoundedBlockedWriteAndUnsupportedCommand
+--- PASS: TestQMPBoundedBlockedWriteAndUnsupportedCommand (0.03s)
+=== RUN TestQMPDuplicateOrMalformedJSON
+--- PASS: TestQMPDuplicateOrMalformedJSON (0.00s)
+=== RUN TestQMPRejectsMissingGreetingPromptly
+--- PASS: TestQMPRejectsMissingGreetingPromptly (0.00s)
+FAIL
+FAIL wootc 4.080s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-counter-test.go b/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-counter-test.go
new file mode 100644
index 00000000..874b496a
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-counter-test.go
@@ -0,0 +1,7 @@
+package main
+import("bufio";"context";"fmt";"io";"testing";"time")
+func TestMissingCapabilityReturnMustRefuse(t *testing.T){
+ childIn,parentIn:=io.Pipe();parentOut,childOut:=io.Pipe();defer childIn.Close();defer parentIn.Close();defer childOut.Close();defer parentOut.Close()
+ go func(){fmt.Fprintln(childOut,`{"QMP":{"version":{},"capabilities":[]}}`);r:=bufio.NewScanner(childIn);if r.Scan(){fmt.Fprintln(childOut,`{"id":"1"}`)}}()
+ ctx,cancel:=context.WithTimeout(context.Background(),time.Second);defer cancel();if _,err:=connectQMP(ctx,parentIn,parentOut);err==nil{t.Fatal("missing capability return accepted as successful negotiation")}
+}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-mutant.log
new file mode 100644
index 00000000..19a47f7e
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/missing-return-mutant.log
@@ -0,0 +1,20 @@
+=== RUN TestQMPTypedResponseCounterexamples
+=== RUN TestQMPTypedResponseCounterexamples/{"id":"2"}
+ vm_qmp_protocol_test.go:39: invalid response accepted
+=== RUN TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"}
+=== RUN TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+--- FAIL: TestQMPTypedResponseCounterexamples (0.01s)
+ --- FAIL: TestQMPTypedResponseCounterexamples/{"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+FAIL
+FAIL wootc 0.022s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/old-counter-provenance.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/old-counter-provenance.json
new file mode 100644
index 00000000..c48f647a
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/old-counter-provenance.json
@@ -0,0 +1,18 @@
+{
+ "counterexample": "actual old/current connectQMP with successful-looking matching ID but missing return",
+ "baselineSource": "8e1c93e",
+ "runs": [
+ {
+ "name": "before",
+ "exit": 1,
+ "sourceSha256": "fb4d2f441cf8d560d9f1aaa074b3e0e4c0a3f38f262f9bcdb8b8021af6400ff7",
+ "logSha256": "ba8d036bd7a83a6e4f69cd7d5f4c93e7524abcd63936d79a61f9fe898ab52520"
+ },
+ {
+ "name": "after",
+ "exit": 0,
+ "sourceSha256": "5f1fc719954924b10d2c9941545a7f538caf2d0c77ca5acac4aae5b4671fed04",
+ "logSha256": "33756c0ae90328dc725bbb39ef64b7c18cdf2a08b98b00e0183b5763c25034e5"
+ }
+ ]
+}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/old-missing-return.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/old-missing-return.raw.log
new file mode 100644
index 00000000..41c785f5
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/old-missing-return.raw.log
@@ -0,0 +1,6 @@
+=== RUN TestMissingCapabilityReturnMustRefuse
+ missing_return_test.go:6: missing capability return accepted as successful negotiation
+--- FAIL: TestMissingCapabilityReturnMustRefuse (0.00s)
+FAIL
+FAIL command-line-arguments 0.012s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/prose.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/prose.log
new file mode 100644
index 00000000..34648d3c
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/prose.log
@@ -0,0 +1,42 @@
+ 228 docs/agent-lessons.md
+ 227 docs/SPEC.md
+ 125 docs/phase2-attach-postmortem.md
+ 76 docs/borrowed-from-libertix.md
+ 70 docs/ntfs-on-linux.md
+ 65 docs/phase2-debug-plan.md
+ 64 docs/plugin-architecture.md
+ 62 docs/branding-and-distribution.md
+ 49 docs/gui-phase1-architecture.md
+ 46 docs/release-notes-v0.3.0-beta.md
+ 45 docs/manual-testing.md
+ 39 docs/philosophy.md
+ 34 docs/upstream-blessings.md
+ 32 docs/finish-plan.md
+ 31 docs/gui-walkthrough.md
+ 30 docs/session-migration.md
+ 29 docs/milestones.md
+ 27 docs/status.md
+ 24 docs/non-bootc-adoption.md
+ 23 docs/adr/0004-composefs-kernel-module-matching.md
+ 20 docs/alpha-adopter-program.md
+ 19 docs/branding.md
+ 18 docs/adr/0004-restore-vm-first-product.md
+ 17 docs/architecture-boundary.md
+ 15 docs/docs-truth-pass.md
+ 12 docs/adr/0002-hardware-preflight-bootc-matching.md
+ 12 docs/experiments/vm-helper-2026-09-26.md
+ 12 docs/prefilled-profile-testing.md
+ 9 docs/adr/0001-phase1-first-architecture.md
+ 9 docs/branded-walkthroughs.md
+ 9 CONTRIBUTING.md
+ 7 docs/plan.md
+ 6 docs/adr/0003-onedrive-files-on-demand-migration.md
+ 6 docs/vhdx-exploration.md
+ 4 docs/backend-contract.md
+ 4 docs/soak.md
+ 4 docs/systemd-boot.md
+ 1 docs/artifact-authentication.md
+ 1 docs/specs/migration-extensions.md
+ 1 docs/windows-state-trust.md
+
+1512 finding(s) across 40 file(s)
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/provenance.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/provenance.json
new file mode 100644
index 00000000..25830a3b
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/provenance.json
@@ -0,0 +1,62 @@
+{
+ "capturedAt": "2026-09-27T15:24:30.324544+00:00",
+ "sourceCommit": "4cbdf3867f739d6a33585784fb7330184f80a2e2",
+ "GoSourceEquivalent": "b6c4c6f02c31beaa248daaaefd456e04625fb429",
+ "sourceBase": "8e1c93e",
+ "integratedMain": "347f696ef855866e2230dc4e761a1caf11609657",
+ "codeHashes": {
+ "app/vm_qmp.go": "1a434e11459306dafc415eac323e98f4c3f5d8a2b1ee94bfb9568dcdfe12e66f",
+ "app/vm_process.go": "e250903b5aab55e02533ca9e8c3cdea23bc1ce2f8a2cb874b6826f9af639ebde",
+ "app/vm_display.go": "fb0404e638d465a080ad487b41e652eed387198af380e6b2b4859f53e0644b5f",
+ "app/vm_display_test.go": "9bfe710c1dbac1163ed043f771ef8ad8a4652e21d254801cb7ec53c18e1e5b5a",
+ "app/vm_qmp_protocol_test.go": "40b3d2cfff5f524a76403aeccda11b0a13067b056302400f6305397336539bc1",
+ "app/vm_qmp_native_test.go": "370789730e36a215eb9e13dbc4b6d1614d4d0899ebb2dad8c9bfd133b32dac74",
+ ".github/workflows/vm-qmp-controls.yml": "d2f6e587b175d219f30b411b3e24b8fd5c6c137ba88481fd7e3ae5a359b92aaf",
+ "docs/experiments/vm-scoped-qmp-2026-09-27.md": "ac4451021b67ef21c05ecb40dde792d51af7db4f6c8cf4dc534a673be0b88852"
+ },
+ "evidenceHashes": {
+ "prose.log": "9233fe5e6202ff272151a3f6374c2e9477434220cd2c52a701f060c16ad13f80",
+ "old-missing-return.raw.log": "ba8d036bd7a83a6e4f69cd7d5f4c93e7524abcd63936d79a61f9fe898ab52520",
+ "inherited-desktop-after.raw.log": "c5ad051b6ef0db183817f647ef6efb104e74ddb6e73eec80e887cb18562463c0",
+ "session-correlation-mutant.log": "0671031a7a4638ce85e54ea898105b63d7f4cff2633073c4ac92a1a355da7929",
+ "capture-budget-before-qmp-mutant.log": "36e53c29d37e751ce4b006cd6837252f31c59f87017a0d6042522e1e1d213d21",
+ "guard-mutations.json": "d6bbc8da0d989b84eb77f4ce2869e43cda4f8753ed8364220a4684b4e670d240",
+ "running-observation-mutant.log": "59c6d3bb43789ca25aac3efa6abb10bb752ecf717627bb42a4794c0ce3febfa9",
+ "inherited-desktop-before.raw.log": "1562ee3ddb345a34993ed60b43ac5de9c6e0c6d5c6aeb1906f7ee77fbbcc004d",
+ "full-go-b6.raw.log": "efe4f0895914c6fbdcc8f76db21f16a9ff3fd1af3200f924c5f1e5ea2e5f1523",
+ "actionlint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "missing-return-counter-test.go": "06e16ee2bbb09efe8b84e7fd4d520b25bcf788f446539466639372ce6a734b0f",
+ "full-go21dd.raw.log": "af5b07d56923028346f45f7e3e532236e378063c3bf66a913bedb31a7c72db51",
+ "initial-race-fixture-deadline.raw.log": "0d18d11cd1912de9ebdf64d98d97ff08632db8143803c70de1ec907bbdc2fe1a",
+ "engine-owner-after-response-mutant.log": "2e930c947fdcb6f400ae366e28723693399108a298fa5d941ffd7893a0812137",
+ "old-counter-provenance.json": "cd93768524c26f647660f348bbc0adee26776bf68543f2f20b12c2662183b225",
+ "missing-return-mutant.log": "928cb8e5370d39cda5f0ce10f306a2cce87642856b1b8cc70be50ef2ec7f3ab5",
+ "race21dd.raw.log": "4a23a0923aac20c1b821b4a5e06daf563919912861037f652fd14c59f2a40309",
+ "current-missing-return.raw.log": "c1a381016d08ca30577e5f9235206fa14cc7950a087aa12b53f39f22d34ed137",
+ "race-b6.raw.log": "a7d65073b80a3f32e30747b1d59461ce7e9f2dcef05460388ba66673a01bba3e",
+ "actual-retained-bytes-mutant.log": "d03da5af1e3fd48c70921a69fed880786c06e759e0a98783ee23286362a04c9c",
+ "capture-pixels-mutant.log": "6f78096696b9e385b261357fed281d60c6985ccf9f38726895a2f3172913e1d8"
+ },
+ "native": {
+ "binary": "/usr/bin/qemu-system-x86_64",
+ "binarySha256": "8a35ccba41582fc6c38b9df85fc9e35fa1d42f414d2d7d8090ee9b2f5e7c0854",
+ "version": "QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)",
+ "scope": "paused TCG -S;128MiB;no disk/network/guest installation;protocol only"
+ },
+ "checks": {
+ "focusedRace": "PASS;native required;zero skips",
+ "fullGo": "PASS",
+ "guardRemovals": 7,
+ "sourceChecks": {
+ "actionlint": 0,
+ "prose": 0
+ },
+ "EnglishBudget": 1512
+ },
+ "historicalProofs": {
+ "21dd": "immutable typed QMP/display/retention source;seven guardmutants bound to unchanged qmp/display bytes",
+ "b6": "adds DesktopReady false initialization and actual before/after child control",
+ "initialRaceFailure": "mock race binary one-second exit hook met one-second fixture deadline;fixture adjusted to three seconds,production unchanged"
+ },
+ "scope": "internal engine operations only;test RPC adapter and guest semantic channel remain owed;no Windows/WHPX or desktop acceptance inferred;no full VM dispatch or preserved fixture change"
+}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/race-b6.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/race-b6.raw.log
new file mode 100644
index 00000000..60e90e2d
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/race-b6.raw.log
@@ -0,0 +1,78 @@
+=== RUN TestVMDisplayChild
+--- PASS: TestVMDisplayChild (0.00s)
+=== RUN TestVMDisplayActionsAndSessionNonce
+--- PASS: TestVMDisplayActionsAndSessionNonce (0.08s)
+=== RUN TestVMDisplayIdentityAndReplayRefuseBeforeGuest
+--- PASS: TestVMDisplayIdentityAndReplayRefuseBeforeGuest (0.03s)
+=== RUN TestVMDisplayUnknownOrFailedObservations
+=== RUN TestVMDisplayUnknownOrFailedObservations/paused
+=== RUN TestVMDisplayUnknownOrFailedObservations/fake-status
+=== RUN TestVMDisplayUnknownOrFailedObservations/ambiguous-mice
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-pixels
+=== RUN TestVMDisplayUnknownOrFailedObservations/missing-capture
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-key-ack
+--- PASS: TestVMDisplayUnknownOrFailedObservations (0.21s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/paused (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/fake-status (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/ambiguous-mice (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-pixels (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/missing-capture (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-key-ack (0.04s)
+=== RUN TestVMDisplayKeyValidation
+--- PASS: TestVMDisplayKeyValidation (0.00s)
+=== RUN TestVMDisplayDeadlineAndLifecycle
+--- PASS: TestVMDisplayDeadlineAndLifecycle (0.08s)
+=== RUN TestVMDisplaySymlinkCaptureParentRefused
+--- PASS: TestVMDisplaySymlinkCaptureParentRefused (0.04s)
+=== RUN TestVMDisplayEngineOwnerAndChangedSession
+--- PASS: TestVMDisplayEngineOwnerAndChangedSession (0.19s)
+=== RUN TestVMDisplayForceRemainsAvailableDuringShutdownWait
+--- PASS: TestVMDisplayForceRemainsAvailableDuringShutdownWait (0.04s)
+=== RUN TestVMDisplayCaptureRetentionExhaustion
+--- PASS: TestVMDisplayCaptureRetentionExhaustion (1.09s)
+=== RUN TestVMDisplayActualRetainedByteBudget
+--- PASS: TestVMDisplayActualRetainedByteBudget (0.04s)
+=== RUN TestVMDisplayNeverInheritsDesktopClaim
+--- PASS: TestVMDisplayNeverInheritsDesktopClaim (0.03s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (2.31s)
+ --- PASS: TestManagedVMShutdownContract/clean (1.05s)
+ --- PASS: TestManagedVMShutdownContract/host (1.04s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.04s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.19s)
+=== RUN TestQMPNativePausedComponent
+ vm_qmp_native_test.go:27: QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)
+ Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers
+ vm_qmp_native_test.go:49: actual paused status {"status": "prelaunch", "singlestep": false, "running": false}
+ vm_qmp_native_test.go:61: actual native PPM 640x480 sha256=15e7d086f38e02fb3b8af538b68d76619b0ecf56927333e1ba7510e79346fb03
+--- PASS: TestQMPNativePausedComponent (0.09s)
+=== RUN TestQMPTypedResponseCounterexamples
+=== RUN TestQMPTypedResponseCounterexamples/{"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"}
+=== RUN TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+--- PASS: TestQMPTypedResponseCounterexamples (0.01s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+=== RUN TestQMPBoundedBlockedWriteAndUnsupportedCommand
+--- PASS: TestQMPBoundedBlockedWriteAndUnsupportedCommand (0.03s)
+=== RUN TestQMPDuplicateOrMalformedJSON
+--- PASS: TestQMPDuplicateOrMalformedJSON (0.00s)
+=== RUN TestQMPShutdownNeedsLiteralTypedFields
+--- PASS: TestQMPShutdownNeedsLiteralTypedFields (0.00s)
+=== RUN TestQMPRejectsMissingGreetingPromptly
+--- PASS: TestQMPRejectsMissingGreetingPromptly (0.00s)
+PASS
+ok wootc 5.304s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/race21dd.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/race21dd.raw.log
new file mode 100644
index 00000000..f1960870
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/race21dd.raw.log
@@ -0,0 +1,76 @@
+=== RUN TestVMDisplayChild
+--- PASS: TestVMDisplayChild (0.00s)
+=== RUN TestVMDisplayActionsAndSessionNonce
+--- PASS: TestVMDisplayActionsAndSessionNonce (0.08s)
+=== RUN TestVMDisplayIdentityAndReplayRefuseBeforeGuest
+--- PASS: TestVMDisplayIdentityAndReplayRefuseBeforeGuest (0.04s)
+=== RUN TestVMDisplayUnknownOrFailedObservations
+=== RUN TestVMDisplayUnknownOrFailedObservations/paused
+=== RUN TestVMDisplayUnknownOrFailedObservations/fake-status
+=== RUN TestVMDisplayUnknownOrFailedObservations/ambiguous-mice
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-pixels
+=== RUN TestVMDisplayUnknownOrFailedObservations/missing-capture
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-key-ack
+--- PASS: TestVMDisplayUnknownOrFailedObservations (0.21s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/paused (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/fake-status (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/ambiguous-mice (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-pixels (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/missing-capture (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-key-ack (0.03s)
+=== RUN TestVMDisplayKeyValidation
+--- PASS: TestVMDisplayKeyValidation (0.00s)
+=== RUN TestVMDisplayDeadlineAndLifecycle
+--- PASS: TestVMDisplayDeadlineAndLifecycle (0.08s)
+=== RUN TestVMDisplaySymlinkCaptureParentRefused
+--- PASS: TestVMDisplaySymlinkCaptureParentRefused (0.03s)
+=== RUN TestVMDisplayEngineOwnerAndChangedSession
+--- PASS: TestVMDisplayEngineOwnerAndChangedSession (0.19s)
+=== RUN TestVMDisplayForceRemainsAvailableDuringShutdownWait
+--- PASS: TestVMDisplayForceRemainsAvailableDuringShutdownWait (0.04s)
+=== RUN TestVMDisplayCaptureRetentionExhaustion
+--- PASS: TestVMDisplayCaptureRetentionExhaustion (1.09s)
+=== RUN TestVMDisplayActualRetainedByteBudget
+--- PASS: TestVMDisplayActualRetainedByteBudget (0.03s)
+=== RUN TestManagedVMShutdownContract
+=== RUN TestManagedVMShutdownContract/clean
+=== RUN TestManagedVMShutdownContract/host
+=== RUN TestManagedVMShutdownContract/crash
+=== RUN TestManagedVMShutdownContract/hung
+--- PASS: TestManagedVMShutdownContract (2.30s)
+ --- PASS: TestManagedVMShutdownContract/clean (1.04s)
+ --- PASS: TestManagedVMShutdownContract/host (1.04s)
+ --- PASS: TestManagedVMShutdownContract/crash (0.04s)
+ --- PASS: TestManagedVMShutdownContract/hung (0.18s)
+=== RUN TestQMPNativePausedComponent
+ vm_qmp_native_test.go:27: QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)
+ Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers
+ vm_qmp_native_test.go:49: actual paused status {"status": "prelaunch", "singlestep": false, "running": false}
+ vm_qmp_native_test.go:61: actual native PPM 640x480 sha256=15e7d086f38e02fb3b8af538b68d76619b0ecf56927333e1ba7510e79346fb03
+--- PASS: TestQMPNativePausedComponent (0.10s)
+=== RUN TestQMPTypedResponseCounterexamples
+=== RUN TestQMPTypedResponseCounterexamples/{"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"}
+=== RUN TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"}
+=== RUN TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"}
+--- PASS: TestQMPTypedResponseCounterexamples (0.01s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":null,"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"2","id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{"running":true,"running":false},"id":"2"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"return":{},"id":"unknown"} (0.00s)
+ --- PASS: TestQMPTypedResponseCounterexamples/{"error":{"class":"GenericError","desc":"failed"},"id":"2"} (0.00s)
+=== RUN TestQMPBoundedBlockedWriteAndUnsupportedCommand
+--- PASS: TestQMPBoundedBlockedWriteAndUnsupportedCommand (0.03s)
+=== RUN TestQMPDuplicateOrMalformedJSON
+--- PASS: TestQMPDuplicateOrMalformedJSON (0.00s)
+=== RUN TestQMPShutdownNeedsLiteralTypedFields
+--- PASS: TestQMPShutdownNeedsLiteralTypedFields (0.00s)
+=== RUN TestQMPRejectsMissingGreetingPromptly
+--- PASS: TestQMPRejectsMissingGreetingPromptly (0.00s)
+PASS
+ok wootc 5.257s
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/provenance.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/provenance.json
new file mode 100644
index 00000000..07eddcb9
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/provenance.json
@@ -0,0 +1,21 @@
+{
+ "originalSource": "c9b60777cfac4f721a25994b05c78a796ab63e2f",
+ "runId": 36329610437,
+ "jobId": 108648907878,
+ "scope": "actual frontend module with DOM spies;strict unchanged install host validator;no real GUI or VM acceptance",
+ "vmPreparationFieldCount": 9,
+ "checks": "11 PASS;actual install/reboot/prepare-vm drive loop producers;install/reboot exact twelve fields;prepare-vm nine extra fields;wrong/missing fields refused;removed action guard red",
+ "sourceHashes": {
+ "app/frontend/src/lib/e2e.js": "79d5a1e2d6f6ff3674f7961202ead5f58e8950e1c9bca2595c178af41897ba3b",
+ "tests/unit/gui-drive-producer-controls.cjs": "0d1f1bf2c18bc2ee80e75bf1b2e47d755cdd5eb87eea5e3a3dd563fc6b7b3bd7",
+ "tests/unit/test_gui_drive_receipt.py": "36c87879ba6a46b18c1842201cbd0d8608b7b2e6a17d1389ac5e68191efb42d4",
+ "tests/e2e/gui-drive-receipt.py": "b791144db47510eca70685c4c605af27e0d314f164e8993eb64f4eb012f8a337"
+ },
+ "evidenceHashes": {
+ "wootc-495-c9-fast-failure.raw.log": "ca33984d448790a5ed085b55e3f0b38d2bf1190135c8dfcd09ef60bcc7846792",
+ "wootc-495-c9-fast-provenance.json": "adf2794adb8235a7464b6a4d9c890c44698b5e9a1a301b9f6e589e7613d71457",
+ "wootc-495-c9-local-drive.raw.log": "75d6e8c072b009d83e639f13a2b59fd27965a6fffd3e67837712aa365cb471e6",
+ "wootc-495-c9-drive-control.json": "049f4e894d0729f249592c8796281d205151c16c1a8332f23fe085939cc679db",
+ "wootc-495-action-receipt-controls.raw.log": "ce3d2dbf79b50bf81e0d2db5e60ad8658dece2b119f8f334a9bfc30a6ff78de0"
+ }
+}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-action-receipt-controls.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-action-receipt-controls.raw.log
new file mode 100644
index 00000000..06ad6757
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-action-receipt-controls.raw.log
@@ -0,0 +1,5 @@
+...........
+----------------------------------------------------------------------
+Ran 11 tests in 0.822s
+
+OK
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-drive-control.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-drive-control.json
new file mode 100644
index 00000000..cb916828
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-drive-control.json
@@ -0,0 +1 @@
+{"controls":8,"mismatchReceipt":{"schemaVersion":1,"runId":"current","directiveId":"1234567890abcdef1234567890abcdef","action":"install","screen":"launchpad","installDriven":false,"installBtnDisabled":false,"hint":"","progressStep":"","error":null,"selectedRef":"unrequested-default","imageMismatch":true,"vmPrepareDriven":false,"freshVmAvailable":false,"freshVmReason":"","vmPrepareButtonVisible":false,"vmPrepareButtonDisabled":null,"vmRuntimeButtonVisible":false,"vmImageMismatch":false,"vmReady":false,"vmProgressStage":""},"thrownClickReceipt":{"schemaVersion":1,"runId":"current","directiveId":"1234567890abcdef1234567890abcdef","action":"install","screen":"done","installDriven":false,"installBtnDisabled":false,"hint":"","progressStep":"armed","error":null,"selectedRef":"ghcr.io/tuna-os/yellowfin:gnome","imageMismatch":false,"vmPrepareDriven":false,"freshVmAvailable":false,"freshVmReason":"","vmPrepareButtonVisible":false,"vmPrepareButtonDisabled":null,"vmRuntimeButtonVisible":false,"vmImageMismatch":false,"vmReady":false,"vmProgressStage":""},"scope":"actual frontend module with DOM and bridge spies","installReceipt":{"schemaVersion":1,"runId":"current","directiveId":"1234567890abcdef1234567890abcdef","action":"install","screen":"done","installDriven":true,"installBtnDisabled":false,"hint":"","progressStep":"armed","error":null,"selectedRef":"ghcr.io/tuna-os/yellowfin:gnome","imageMismatch":false,"vmPrepareDriven":false,"freshVmAvailable":false,"freshVmReason":"","vmPrepareButtonVisible":false,"vmPrepareButtonDisabled":null,"vmRuntimeButtonVisible":false,"vmImageMismatch":false,"vmReady":false,"vmProgressStage":""}}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-failure.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-failure.raw.log
new file mode 100644
index 00000000..72119a24
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-failure.raw.log
@@ -0,0 +1,1054 @@
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4206662Z ##[group]Run bash tests/run.sh fast
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4206987Z ^[[36;1mbash tests/run.sh fast^[[0m
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4271190Z shell: /usr/bin/bash -e {0}
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4271467Z env:
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4271676Z GOTOOLCHAIN: local
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4271995Z WOOTC_COVERAGE_DIR: /home/runner/work/_temp/go-coverage
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4272458Z WOOTC_COVERAGE_BASE_SHA: 8e1c93e5a157435000cd3402dc07abdc95213081
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4272850Z ##[endgroup]
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4429177Z ══ fast tier ═════════════════════════════════════════════════════════
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:46.4429839Z ── bats unit suites (tests/unit) ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:48.8163446Z 1..645
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.0499803Z ok 1 GNOME look-apply exits 0 and writes the marker with no pins
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.1950418Z ok 2 KDE look-apply exits 0 with no pins
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.3153401Z ok 3 unknown desktop still exits 0 and records the marker (mapped=none)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.4627131Z ok 4 the once-only marker suppresses a second apply
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.4888099Z ok 5 no slurp data is a clean exit 0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.6615971Z ok 6 GNOME dock favorites built from taskbar pins (refactor regression guard)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.8222582Z ok 7 KDE panel launchers built from the same pins, targeting the task-manager applet
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:49.9756157Z ok 8 KDE favorites fall back to a default group when no appletsrc exists
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.1008808Z ok 9 XFCE is retired (X11/out of scope) — clean exit, mapped=none
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.1792075Z ok 10 look turned OFF in the chooser is skipped
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.3278736Z ok 11 a BROKEN selection helper must NOT block the migration (fail open)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.4519736Z ok 12 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.5889286Z ok 13 keeps the N newest runs and prunes the rest
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.6722322Z ok 14 serial and logs from pruned runs are preserved as evidence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.7247036Z ok 15 a failed evidence copy retains the complete source run
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.7770410Z ok 16 a successful exit without copied bytes cannot authorize deletion
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.8384915Z ok 17 failed run and evidence enumeration cannot delete old runs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.9064596Z ok 18 actual source deletion follows a verified complete text archive
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:50.9844604Z ok 19 the bulk (video) is NOT preserved — that is the point
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.0739229Z ok 20 the evidence directory is never itself pruned
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.1028389Z ok 21 an empty or missing artifacts dir is a clean no-op
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.1373824Z ok 22 fewer runs than the keep count prunes nothing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.1655309Z ok 23 retention runs on startup so a run cannot be blocked by its predecessors
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.1911880Z ok 24 preflight demands headroom for two runs, not the bare minimum for one
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.2151207Z ok 25 a hosted runner's ~114 GiB clears the requirement
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.2372174Z ok 26 the requirement is overridable for unusual hosts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.2607103Z ok 27 the cached-ISO and reuse paths keep proportional headroom
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.2825330Z ok 28 the keep count is overridable
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.3114981Z ok 29 the README timelapse only publishes green runs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.3443489Z ok 30 per-distribution walkthrough cuts survive each publish
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.3719393Z ok 31 the timelapse gives viewers time to read: 5s cards and freeze-frames
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.4043991Z ok 32 brand runs drive the BRANDED installer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.4363938Z ok 33 the exe pins its boot artifacts to its own release, never to latest
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.5569238Z ok 34 deployer accepts auto before image backend detection
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.5875520Z ok 35 E2E and Windows setup pass auto through by default
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.6268705Z ok 36 backend detection falls back to safe defaults when the probe fails or is ambiguous
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.6509589Z ok 37 current bootupd versioned EFI layout is recognized as ostree
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.6778958Z ok 38 ESP staging supports the current versioned shim and GRUB layout
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.7109221Z ok 39 ESP staging logs every selected source and fails closed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.7359314Z ok 40 initramfs regen KVER comes from the module tree that owns vmlinuz
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.7586400Z ok 41 deploy.sh uses no binaries absent from the initramfs closure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.7856685Z ok 42 filesystem defaults: xfs unsealed, ext4 sealed (btrfs blocked on #35)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.8077823Z ok 43 dracut regen failures report dracut's own output
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.8376922Z ok 44 the backend probe acquires the image before inspecting it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.8618111Z ok 45 verification does not assume the ostree 3-partition layout
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.8834299Z ok 46 a composefs deployment root is not prepared as a chroot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.9068992Z ok 47 an image with no NTFS driver fails the deploy, not Phase 2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.9329474Z ok 48 Phase 2 never ships without an NTFS driver
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.9571104Z ok 49 no unbounded podman call in the deployer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:51.9809778Z ok 50 a failed deploy puts its own log tail on the serial
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.0032662Z ok 51 the NTFS gate uses the same capability test as the injector
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.0249179Z ok 52 ntfs-3g injection acquires the image before running dnf in it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.0532795Z ok 53 ntfs-3g injection self-heals a broken rpmdb before escalating
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.0837116Z ok 54 an image without an NTFS driver is not refused when the deployer has one
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.1110803Z ok 55 no assignment in the deployer aborts on a non-matching grep or empty ls
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.1359535Z ok 56 teardown never turns a successful deploy into a failure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.1608880Z ok 57 the deployer names the command that aborts it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.2174914Z ok 58 the launchpad sends windowsLook with the install config
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.2457857Z ok 59 Wi-Fi collection runs outside the WindowsLook gate
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.2825325Z ok 60 Wi-Fi ordering assertion rejects collection moved inside the look gate
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.4307077Z ok 61 the real tree lists every brand that has not been declined
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.5961681Z ok 62 a declined project's exe drops out of the release matrix
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.6626753Z ok 63 a brand with no recorded decision is an error, not a default yes
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.7186433Z ok 64 an unrecognised status is an error, never treated as buildable
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.7908704Z ok 65 WOOTC_REQUIRE_BLESSING tightens pending brands out without a code change
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.8713932Z ok 66 a pending brand still builds, and the log says it is unblessed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:52.9060272Z ok 67 the release workflow selects brands through the gate, not a bare glob
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.1265389Z ok 68 every brand renders a complete winget manifest set
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.1827349Z ok 69 a rendered manifest never claims a permission nobody has granted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.2357209Z ok 70 the renderer refuses a brand with no record to offer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.2675945Z ok 71 no branded package is auto-submitted to winget
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.2911423Z ok 72 the README table and the records cannot drift apart
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.3808799Z ok 73 btrfs deploys bake an early modules-load.d entry into the Phase-2 initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.4095887Z ok 74 the attach hook clears the btrfs udev readiness gate without modprobe
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.4302785Z ok 75 the dracut module carries btrfs.ko and (optionally) btrfs-progs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.4641500Z ok 76 the filesystem axis reaches the deployer from every harness layer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.5089994Z ok 77 a btrfs deploy is refused when the target kernel cannot load btrfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.5350419Z ok 78 the Phase-2 emergency verdict reads the whole boot, and names the btrfs case
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.6457898Z ok 79 the Phase-2 → Windows reboot requires the guest to actually go down
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.6672378Z ok 80 ext4 stays the sealed default; btrfs works on supported kernels via opt-in
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.7424335Z ok 81 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.7663072Z ok 82 the ssh image is built BEFORE compose needs it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.7902933Z ok 83 a failed image build aborts instead of proceeding
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.8098807Z ok 84 the build is verified to have actually produced the image
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.8326129Z ok 85 compose_up_windows's return value is NOT ignored
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.8532760Z ok 86 container existence is verified even when compose reports success
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.8738217Z ok 87 the failure message names the actual cause and the fix
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.8963354Z ok 88 startup failure exits rather than falling through to the QEMU wait
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.9644978Z ok 89 the hook is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:53.9854843Z ok 90 the hook starts NO background job
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.0061148Z ok 91 no sleep-based watchdog remains in the code
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.0266580Z ok 92 no cancel_watchdog / WATCHDOG_PID machinery remains
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.0465733Z ok 93 the deployer exit status is still reported to kmsg
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.0666306Z ok 94 the failure path still reboots back to Windows
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.0853745Z ok 95 the removal is documented so it is not reintroduced
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.2028842Z ok 96 both scripts are syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.2279050Z ok 97 a kernel reboot is NOT treated as a successful deploy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.2494214Z ok 98 a kernel reboot with no verification summary fails loudly
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.2704341Z ok 99 the kernel-reboot failure explains that Phase-2 setup never ran
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.2916285Z ok 100 Windows answering QGA without completion is a failure, not a wait
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.3113840Z ok 101 the streak resets when Windows is NOT answering
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.3313135Z ok 102 the failure prints the deployer's own log, not just a verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.3523695Z ok 103 the post-fisherman verification stretch is instrumented
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.3748498Z ok 104 no staged binary is executed during the deploy any more
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.3964521Z ok 105 the dracut regen is bounded — it cannot hang the deploy forever
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.4184889Z ok 106 a regen timeout aborts rather than booting a stale initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.4380625Z ok 107 the regen announces itself before starting
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.4592465Z ok 108 both dracut regeneration paths explicitly include OSTree prepare-root
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.4822873Z ok 109 the generated initramfs guard requires wired OSTree prepare-root
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.5031810Z ok 110 the OSTree wiring guard accepts lsinitrd symlink output
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.5301268Z ok 111 the deployed chroot has a valid sticky var tmp before dracut
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.5547808Z ok 112 ostree usr-local backing directory exists before bridge installation
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.5839201Z ok 113 post-install writes use the OSTree stateroot var visible at runtime
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.6095029Z ok 114 stateroot paths need no dirname binary in the minimal initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.6436574Z ok 115 post-install payload is relabeled and Phase-3 label is verified
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.6663759Z ok 116 nonfatal Phase-2 checks are still collected into one verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.6875803Z ok 117 the collected problems are reported as one summary
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.7072467Z ok 118 a clean Phase-2 setup says so explicitly
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.7300008Z ok 119 instrumentation brackets the dracut module copy and the closure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.8142951Z ok 120 find_ldso locates the dynamic loader this deployer runs on
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.8784095Z ok 121 the closure resolves with neither ldd nor awk on PATH
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.9172393Z ok 122 the loader is preferred over ldd, so a broken ldd cannot break staging
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:54.9589273Z ok 123 a binary with no closure still reports none — no phantom loader
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.0412194Z ok 124 the fallback qemu-ga stages end-to-end with no ldd in the initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.0680297Z ok 125 no call site pipes ldd into awk any more
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.1060756Z ok 126 best-effort stagers warn, they do not [FAIL]
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.1254479Z ok 127 the initramfs also carries ldd, as the second path
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.2091928Z ok 128 deploy.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.2372766Z ok 129 the premise holds: run-e2e.sh really does kill the deploy on [FAIL]
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.2629759Z ok 130 every [FAIL] in deploy.sh terminates the deploy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.2945711Z ok 131 the scanner is not vacuous — a planted non-fatal [FAIL] is caught
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.3229625Z ok 132 a [FAIL] behind a return needs every call site to exit
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.3554897Z ok 133 the six sites this suite was written for stayed downgraded
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.5168222Z ok 134 E2E stages the GRUB configuration embedded by the application
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.5417909Z ok 135 docs point at the state file that actually exists
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.6211641Z ok 136 every C:\wootc path the docs name is one the code builds
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.6760819Z ok 137 the branded exe names the docs advertise are the ones brand.json builds
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.6990781Z ok 138 RELEASING does not claim a single published artifact
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.8839695Z ok 139 docs do not link to files that are not in the tree
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.9116187Z ok 140 no doc promises BitLocker works while the channel gate refuses it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.9343469Z ok 141 the documented default channel is the one the code defaults to
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.9612883Z ok 142 the image the docs call the alpha default is the one that gets selected
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:55.9884579Z ok 143 the free-space figure is the one the launchpad enforces
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.0150088Z ok 144 the first screen the docs quote is the one the build renders
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.0449186Z ok 145 the uninstall entry the docs name is the one the installer registers
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.0696662Z ok 146 the migration dashboard is called what the app calls it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.0982815Z ok 147 documented buttons and toggles exist in the frontend
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.1269302Z ok 148 no doc claims real-hardware verification before the ladder banks it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.1698027Z ok 149 every branded walkthrough has its four screenshots on disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.1971386Z ok 150 guide describes the explicit Windows-side Linux boot choice
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.2216778Z ok 151 guide discloses Firefox profile passwords instead of blanket secret exclusion
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.2477310Z ok 152 guide describes Windows removal as a plan without a consumer execution path
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.2807678Z ok 153 guide qualifies uninstall restoration and preserves old section anchors
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.3196074Z ok 154 release guide matches the tagged native graduation gate and its waiver
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.3465477Z ok 155 release user instructions disclose Install writes before reboot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.3728864Z ok 156 release user instructions describe Windows return and explicit Linux boot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.4003477Z ok 157 release user instructions qualify cleanup and disclose the data choice
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.4292348Z ok 158 getting started discloses preparation beyond a folder and boot entry
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.4574309Z ok 159 getting started qualifies cleanup and exposes its data choice
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.4868281Z ok 160 getting started qualifies reputation prompts instead of promising bypass
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.5128659Z ok 161 getting started distinguishes checksums from publisher identity
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.5422157Z ok 162 startup guides disclose the Windows WebView2 runtime dependency
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.5682608Z ok 163 roadmap separates native preview work from complete consumer proof
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.5941122Z ok 164 roadmap separates restoration requirement from current partial cleanup
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.6215863Z ok 165 roadmap requires a SmartScreen observation beyond a valid signature
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.6483704Z ok 166 README qualifies the VM goal against current desktop evidence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.6774723Z ok 167 README exposes preparation settings and normal Windows return
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.7061179Z ok 168 README separates cleanup from restoration and Linux data removal
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.7333191Z ok 169 README matches release graduation and its manual waiver
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.7575489Z ok 170 README qualifies browser secrets instead of a blanket exclusion
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.7850111Z ok 171 README discloses runtime and distinguishes hashes from signed manifests
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.8097392Z ok 172 E2E architecture separates historical native diagrams from current acceptance
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.8457950Z ok 173 E2E architecture follows the actual sourced runner boundaries
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.8743276Z ok 174 E2E architecture does not claim an atomic deployer menu handoff
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.8991573Z ok 175 E2E architecture distinguishes a QGA service request from a working channel
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.9330037Z ok 176 release rollback distinguishes remote withdrawal from signed local cache
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.9660211Z ok 177 manual hardware guide discloses actual preparation outside the state folder
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:56.9947468Z ok 178 manual hardware guide separates requested return and cleanup from restoration
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:57.0179745Z ok 179 manual hardware guide qualifies the legacy path instead of claiming VM-first or WinUI proof
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:57.1070931Z ok 180 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:57.1333720Z ok 181 deadline_in returns an absolute epoch in the future
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:57.1663214Z ok 182 past_deadline is false before and true after the deadline
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.2036580Z ok 183 a deadline elapses in real time even when the loop body blocks
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.2298253Z ok 184 elapsed_min_since reports whole minutes of real time
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.2585646Z ok 185 no wait loop is bound by a tick counter
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.2815717Z ok 186 the long-running loops use past_deadline
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.3035072Z ok 187 progress lines report real minutes against the real budget
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.3293202Z ok 188 the deploy budget is calibrated against real wall-clock behaviour
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.3511320Z ok 189 every QGA call is bounded by a timeout
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.3760932Z ok 190 the QGA call timeout is overridable for slow hosts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.3981931Z ok 191 Phase-2 boot failure reports actual waited time, not the nominal budget
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.4272912Z ok 192 an abort before the full cleanup trap still stamps the run-state file
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.5943457Z ok 193 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.7008714Z ok 194 fail() records to the ledger, not just stderr
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.8049206Z ok 195 a fail inside a command substitution still reaches the ledger
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.9093964Z ok 196 a fail inside a pipeline still reaches the ledger
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:27:59.9778506Z ok 197 the success banner is gated on the ledger being empty
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.0469965Z ok 198 the gate exits non-zero and says TESTS FAILED when the ledger is non-empty
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.1123209Z ok 199 the user-data check records a failure — it is the North Star assertion
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.1826460Z ok 200 Phase-2 health failures preserve the firstboot unit result before reboot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.2521501Z ok 201 a recovered ntfs3 mount error does not fail the run
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.3362765Z ok 202 a drm_panic handler registration is not a kernel panic
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.4033789Z ok 203 OEM payload transfers retry and report instead of aborting silently
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.4848269Z ok 204 a non-matching grep in a checked assignment cannot abort the run
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.5626003Z ok 205 a probe for something absent cannot print a [FAIL] that aborted nothing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.6346962Z ok 206 log helpers do not mangle Windows paths
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.7090291Z ok 207 a locked OEM payload file is force-replaced, not just unheld
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.7783844Z ok 208 the serial arms the deployer-death detector, not just the heartbeat
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.8523924Z ok 209 an unreachable Phase-2 agent is inconclusive, not proof of data loss
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:00.9219818Z ok 210 drive mode can only install the image the directive named
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.0075282Z ok 211 flake auto-retry: only harness-classified flakes are ever re-dispatched
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.0780211Z ok 212 the North Star read polls the bridge, it does not race it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.1467434Z ok 213 the refresh is gated: no trust helper, no chain changes
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.1658654Z ok 214 only a vendor directory wootc owns is touched
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.1853381Z ok 215 shim is replaced LAST, after GRUB and MokManager
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.2078855Z ok 216 the current chain is archived before anything replaces it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.2317320Z ok 217 the candidate is graded against the firmware AND the installed SBAT
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.2781860Z ok 218 SBAT is read from the .sbat section, not the first matching string
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.3037928Z ok 219 the helper and the update trigger travel into the deployment
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.3812447Z ok 220 the verifier grades all four criteria
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.4040459Z ok 221 an unsigned binary fails instead of reading as 'nothing wrong'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.4271098Z ok 222 a missing exe identity fails instead of passing by absence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.4475129Z ok 223 the expected per-brand identity comes from brand.json, not a copy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.4697394Z ok 224 an asset absent from SHA256SUMS fails rather than being skipped
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.4917087Z ok 225 a stale winget manifest fails even though the package resolved
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.5205789Z ok 226 the checklist cannot be talked into passing, and names its manual half
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.5406576Z ok 227 the elevation manifest is intact in the committed resource object
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.5666919Z ok 228 the release doc records signing blockers and per-brand resources
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.5857365Z ok 229 the PowerShell graders are actually run by the fast tier
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.7915135Z ok 230 E2E invokes go-native by absolute path under QGA's minimal PATH
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.8544319Z ok 231 go-native supplies sbin paths for GUI and QGA service environments
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:01.9279988Z ok 232 --phase3 provisions its dedicated blank target and handles none found
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.0009372Z ok 233 Phase-3 QGA uses the request-gated systemd boundary
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.0725360Z ok 234 Phase-3 dispatcher rechecks blank target before destructive gate
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.1445363Z ok 235 Phase-3 dispatcher runs NTFS shrink verification before migration
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.2302303Z ok 236 migrate --dual-boot on loopback describes the graduate stages and touches nothing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.3582259Z ok 237 migrate --dual-boot with shrink fixture shows available space and safe defaults
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.4413788Z ok 238 migrate --dual-boot with dirty NTFS fixture shows the dirty-volume diagnostic
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.5298066Z ok 239 migrate --dual-boot with no-space NTFS fixture shows the nospace diagnostic
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.6069708Z ok 240 plan when already native says nothing to graduate
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.8414560Z ok 241 check reports loopback + counts converted folders, never touches disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.9237921Z ok 242 check verifies NTFS shrinkability when layout is discoverable
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:02.9971429Z ok 243 check warns on dirty NTFS but does not hard-block
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.0651986Z ok 244 migrate --reclaim REFUSES while still on loopback
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.1346034Z ok 245 migrate --reclaim REFUSES when no folders are verified native
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.2142480Z ok 246 migrate --execute REFUSES without WOOTC_GN_ALLOW_DESTRUCTIVE=1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.2802685Z ok 247 migrate with no mode REFUSES
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.3607159Z ok 248 migrate --dual-boot (no --execute) is a dry run that touches no disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.4392589Z ok 249 Phase-3 plan uses recorded registry origin, not deployer-local derived tag
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.5064321Z ok 250 Phase-3 native install supplies the deployer-selected filesystem
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.5693260Z ok 251 Phase-3 home migration selects a partition, never the whole target disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.6437272Z ok 252 Phase-3 schedules and positively verifies the native boot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.7178885Z ok 253 migrate --reclaim dry run (native + converted) prints IRREVERSIBLE plan, no disk touched
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.7849710Z ok 254 migrate REFUSES when the Windows disk cannot be located
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:03.9247133Z ok 255 status --json on loopback: canGraduate true, canReclaim false, touches nothing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.0540553Z ok 256 status --json with shrink fixture reports ntfsShrinkable gate and safeGib
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.1786336Z ok 257 status --json native + converted folders: canReclaim true, folders listed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.2984413Z ok 258 status --json native but NO converted folders: canReclaim false (data-safety gate)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.3896115Z ok 259 migrate --to-disk REFUSES to install onto the Windows disk # skip no loop device
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.4675081Z ok 260 Phase-3 install uses fisherman podman-run mode with the origin image
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.5367834Z ok 261 Phase-3 post-install /etc writes target the deployment etc, not sysroot etc
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.6036042Z ok 262 Phase-3 recipe mirrors the running account into the native install
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.6724753Z ok 263 Phase-3 installs the /run userdata probe for the confined guest agent
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.7545005Z ok 264 the hook is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.7734324Z ok 265 say() writes to /dev/console (the only threshold-independent path)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.7919473Z ok 266 say() also emits an explicit low priority to kmsg for the journal
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.8163083Z ok 267 the hook announces entry before any early return
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.8375554Z ok 268 diagnostics never use dracut info()/warn(), which printk filters away
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.8573223Z ok 269 every failure exit states a reason
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.8777804Z ok 270 the hook reports what appeared after attach, not just that it attached
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.8981159Z ok 271 deployer log()/err() emit an explicit kmsg priority
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.9192703Z ok 272 deployer log()/err() do NOT also write to /dev/console
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.9383757Z ok 273 the low-volume Phase-2 hook DOES still write to /dev/console
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:04.9594248Z ok 274 the initramfs guard aborts the deploy when the attach service is unwired
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.0322309Z ok 275 every hosted workflow that runs containers wires in the runtime repair
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.0505461Z ok 276 the repair installs crun over /usr/bin, where podman actually looks
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.0706106Z ok 277 the repair proves a container can be created, not just that versions look right
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.0901053Z ok 278 the repair never touches self-hosted runners
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.1099378Z ok 279 the #71 arm gate defaults to empty so it can never gate the nightly
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.2312956Z ok 280 scan reports a Linux-safe username and the full name/email/locale
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.3094773Z ok 281 scan always reports the password as NOT migratable
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.3876902Z ok 282 scan finds the account picture
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.4888657Z ok 283 apply copies the account picture to ~/.face and AccountsService
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.5494889Z ok 284 apply with no account picture still succeeds (name only, no crash)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.6691719Z ok 285 scan finds the spare NTFS and the BitLocker disk, not the running disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.7418850Z ok 286 the running-disk (sda) partitions are excluded
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.8125926Z ok 287 BitLocker volume is flagged fs=bitlocker + locked=true
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.8830682Z ok 288 plain NTFS volume is fs=ntfs + locked=false, with its label
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:05.9539051Z ok 289 non-Windows filesystems (ext4) are not offered
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.0054103Z ok 290 empty disk list is a clean empty scan
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.1075845Z ok 291 deploy.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.1305435Z ok 292 wootc-firstboot-evidence is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.1579385Z ok 293 deploy.sh writes deployer-started.json and state.json = deploying right after ntfs-mounted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.1830222Z ok 294 deploy.sh writes state.json = deployed after vstage verify-complete
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.2124991Z ok 295 deploy.sh cleanup writes state.json = failed with phase on non-zero exit
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.2453266Z ok 296 deployer metadata uses the descriptor-preserving atomic writer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.2710038Z ok 297 wootc-firstboot-evidence exists and writes state.json = healthy atomically
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.2945630Z ok 298 wootc-firstboot-evidence.service is ordered after host-bind
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.3164248Z ok 299 firstboot service PATH includes the staged NTFS state writer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.3904921Z ok 300 firstboot health writer publishes readable state and evidence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.4264289Z ok 301 firstboot health writer fails when the private Windows mount is absent
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.4756298Z ok 302 firstboot does not publish its retry marker when the state write fails
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.5008915Z ok 303 first-boot evidence payload is staged by deploy.sh and shipped in module-setup.sh
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.5341550Z ok 304 deployHasCompleted stops trusting journal file alone
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.5622162Z ok 305 state.go defines all six lifecycle states
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.6271389Z ok 306 lifecycle capture retains failing exit even when stdout claims healthy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.6527583Z ok 307 lifecycle capture preserves successful output and separate empty stderr
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.7748240Z ok 308 public view is masked and read-only before publication
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.8349657Z ok 309 read-only failure never publishes the Windows view
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.8686838Z ok 310 privileged lifecycle and folder writers use the private mount
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.9128918Z ok 311 FUSE NTFS fallback keeps private writes available to root services
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:06.9671395Z ok 312 legacy kernel NTFS fallback keeps private writes available to root services
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:07.0369289Z ok 313 folder redirects cannot export installer state or the whole volume
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:07.0888076Z ok 314 a user-created folder symlink is never used as a bind destination
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:07.1476554Z ok 315 a case-sensitive uppercase installer directory is also masked
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:07.6785044Z ok 316 scan discovers present categories and defaults them ON
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:07.9783449Z ok 317 absent category is present=false and defaultOn=false
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:08.2496686Z ok 318 files category lists the actual folders found
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:08.5263266Z ok 319 wifi item reports the number of exported profiles
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:08.7978854Z ok 320 the Public profile is ignored by --all
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.0682806Z ok 321 scan with no discoverable data is a clean empty catalog (no crash)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.1494192Z ok 322 matrix covers Windows 10 and 11 Pro
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.1710076Z ok 323 Home rows are disabled until a Home-capable ISO fixture exists
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.1909115Z ok 324 matrix covers the BitLocker axis
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.2141669Z ok 325 matrix covers all three deployment backends
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.2396439Z ok 326 matrix has a phase3 case per backend flavor
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.2602116Z ok 327 host_worker threads the opts column through to run_case
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.2806743Z ok 328 phase3=on translates to --phase3 on the remote invocation
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.3049938Z ok 329 himachal has a hard ceiling of one concurrent VM
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.3323199Z ok 330 a case waits for a loaded host instead of piling onto it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.3607958Z ok 331 the matrix runs from an immutable snapshot of itself
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.3851757Z ok 332 remote cases are launched as systemd user units, not nohup
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.4054221Z ok 333 the poll reads systemd unit terminal state on abnormal death
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.4282370Z ok 334 --grep is a regex so an iteration loop can name distinct code paths
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.5151143Z ok 335 MokManager (mmx64.efi) travels with the signed boot chain
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.5541955Z ok 336 the deployer queues MOK enrollment for akmods images
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.5858293Z ok 337 MOK certs are harvested before the verify tree is unmounted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.6220590Z ok 338 MokManager is driven from inside the Phase-2 wait, on its own serial marker
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.6436494Z ok 339 the GUI warns about the one-time MokManager screen for images that need it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.6658283Z ok 340 the enrolled kernel actually gets a boot: re-arm after MokManager lands in Windows
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.6912977Z ok 341 the Windows-return marker is scanned in the consumed span, not just fresh output
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.7804804Z ok 342 run-e2e accepts --instance= and derives disjoint state from it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.8083682Z ok 343 compose volumes and container name are instance-parameterized
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.8348570Z ok 344 matrix per-slot cleanup is scoped to its own instance
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.8585347Z ok 345 matrix sizes runners instead of assuming capacity
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.8827360Z ok 346 concurrent deployer builds are serialized, artifacts written atomically
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.9221761Z ok 347 GUI-driven install path: seeds, drives the live form, rejoins normal flow
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.9477296Z ok 348 matrix poll ssh cannot eat the case queue
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:09.9731452Z ok 349 fresh runs reclaim their own disposable disk before preflight
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.0024446Z ok 350 registry mirror is an opt-in, probed hint at every layer
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.0247559Z ok 351 post-deploy parsing survives no-match greps (pipefail)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.0485611Z ok 352 a failed run tears the VM down even under --keep
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.0764303Z ok 353 setup-wootc.ps1 is single-instance (mutex) — no fresh-install OEM race
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.1621781Z ok 354 the Phase-2 boot menu says the word Windows
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.1896756Z ok 355 the Linux entry is named for the product, and stays the default
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.2176555Z ok 356 product Phase-2 boots quiet; observed runs keep the verbose consoles
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.2370309Z ok 357 product tool output never scrolls over the splash
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.2619518Z ok 358 a verified deploy re-arms the next boot — but never under E2E observation
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.2817230Z ok 359 the final splash message is only 'starting Linux' when a Linux boot is armed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.3033166Z ok 360 efibootmgr ships in the deployer initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.3289692Z ok 361 staged boot artifacts come with their manifest
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.3493838Z ok 362 something is on screen before the network is up
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.3731951Z ok 363 a wootc firmware entry can never survive in the permanent boot order
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.3965599Z ok 364 the one-shot arm retries as a unit — bootsequence included
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.4805557Z ok 365 a first-login welcome exists, is visible, and runs once
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.4991930Z ok 366 the welcome opens the migration dashboard, with a fallback that still says hello
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.5251526Z ok 367 the deployer stages the welcome as an autostart — tolerantly, and the initramfs ships it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.6216542Z ok 368 every migration payload deploy.sh stages is shipped in the initramfs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.6451352Z ok 369 every bridged home gets a Windows-drive bookmark
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.7424763Z ok 370 the Windows-drive bookmark also reaches KDE's Dolphin
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.7668665Z ok 371 the timelapse demo stages exist and can never fail a run
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:10.7953784Z ok 372 the NTFS-on-Linux doc cites code that still exists
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.5021184Z ok 373 NTFS state writer rejects forged descriptors and truncated records
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.5390113Z ok 374 firstboot never announces healthy when descriptor preservation fails
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.6266822Z ok 375 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.6456004Z ok 376 the run id is passed to the guest via wootc-config.txt
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.6700125Z ok 377 the guest stamps the run id into the completion marker, not a constant
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.6907250Z ok 378 the guest falls back safely when RunId is absent from the config
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.7114370Z ok 379 the barrier requires the marker to equal this run's id
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.7304858Z ok 380 an empty marker does NOT satisfy the barrier
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.7520119Z ok 381 the barrier no longer passes on a bare readable-file check
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.7756250Z ok 382 the OEM sync uses the CONTAINER's view of the mount, not a host path
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.7945848Z ok 383 C:\OEM is refreshed from the host before setup starts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.8172915Z ok 384 the OEM sync is unconditional, not limited to --skip-install
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.8417098Z ok 385 the refresh happens BEFORE the OEM script is launched
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.8624383Z ok 386 a barrier timeout distinguishes 'never completed' from 'stale marker'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.9303499Z ok 387 the deploy hook fires without a network (settled + online, one guard)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.9587377Z ok 388 a staged OCI bundle is ingested before anything network-shaped runs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:18.9820738Z ok 389 with a bundle, the registry preflight is skipped; without one, the wait is bounded and names the fix
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.0028638Z ok 390 offline deploys skip the dnf injection instead of burning its timeouts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.0308532Z ok 391 the app pre-downloads with fail-closed digest verification
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.1813725Z ok 392 both hooks are syntactically valid and safe to SOURCE
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.2098042Z ok 393 the dracut module registers both hooks under the exact expected names
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.2538579Z ok 394 the early-cpio overlay carries both hooks, and refuses to build without them
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.3385265Z ok 395 the overlay completeness gate actually rejects a hookless overlay
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.3822431Z ok 396 behavioral: nothing is staged when the host NTFS is not mounted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.4441883Z ok 397 behavioral: a Phase-2 boot stages an initramfs that systemd will pivot into
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.5152677Z ok 398 behavioral: an initramfs with no /shutdown arms nothing (fail-safe)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.5908270Z ok 399 behavioral: a staged tree carrying no umount hook is reverted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.6352537Z ok 400 behavioral: staging is skipped rather than risking OOM on a low-memory box
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.6790767Z ok 401 behavioral: an already-populated /run/initramfs is left alone
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.7216850Z ok 402 behavioral: the volume is found at the path dracut's shutdown.sh leaves it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.8022221Z ok 403 behavioral: the other post-pivot spellings are found too
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.8405393Z ok 404 behavioral: a busy volume asks to be retried instead of being abandoned
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.8869724Z ok 405 behavioral: the final pass syncs and lazy-detaches rather than leaving it mounted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:19.9198926Z ok 406 behavioral: a boot with no host NTFS mounted is a no-op, not an unmount
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.0343852Z ok 407 no early-cpio branch references the phantom /mnt/sysroot
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.0695384Z ok 408 all three prepend-cpio branches share one overlay implementation
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.0962948Z ok 409 the deployer-sourced fallback ships the loader and is exec-verified
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.1206880Z ok 410 the built Phase-2 initrd is verified by listing, not by size
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.1602293Z ok 411 a missing mountable installed root is fatal, with evidence (#45)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.2222951Z ok 412 behavioral: target ntfs-3g is staged with its libraries, symlinks intact
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.2896335Z ok 413 behavioral: fallback wrapper hardcodes the '@' argv[0] that survives switch-root
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.3731140Z ok 414 behavioral: a complete overlay + complete base initrd builds and verifies
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.4527150Z ok 415 behavioral: a base initrd without bash is REFUSED (mutation test)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.5150109Z ok 416 behavioral: an overlay with a missing wants edge is refused before packing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.7239464Z ok 417 behavioral: a microcode early-cpio does not hide the real initrd (#76)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:20.9217410Z ok 418 behavioral: a real absence is still fatal behind an early-cpio
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.1002172Z ok 419 behavioral: a microcode-only image is unverifiable, not a failure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.2357195Z ok 420 behavioral: an unlistable base initrd warns but does not fail the deploy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.3412944Z ok 421 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.3664374Z ok 422 guest-ping is OS-agnostic, so it cannot stand alone as a down-detector
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.3948372Z ok 423 a positive Linux discriminator exists alongside the Windows one
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.4190584Z ok 424 no agent answering is read as 'down'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.4430246Z ok 425 a Windows agent answering is read as 'windows', not as a live Phase 2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.4673850Z ok 426 a Linux agent still answering is read as 'linux'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.4914998Z ok 427 an agent that identifies as neither is read as 'unknown', never as Linux
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.5189040Z ok 428 the observation stops the moment Windows is seen, without draining the budget
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.5414784Z ok 429 system_reset in the Phase-2 return is reachable only from the 'linux' verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.5722354Z ok 430 the verdict is computed before the reset, from p2_reboot_observe
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.5951613Z ok 431 the reboot request is bounded so it cannot eat the observation window
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.6159932Z ok 432 the Windows return is still asserted with the OS discriminator afterwards
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:21.6388568Z ok 433 the old ping-only shape really would have reset run 30710282779
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.6757208Z ok 434 mutation: a reboot that does nothing must fail the final Windows-return assertion
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.7578718Z ok 435 the script is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.7939755Z ok 436 picks the blank spare disk, never the Windows disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.8272833Z ok 437 REFUSES when the only non-root disk is the Windows disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.8602514Z ok 438 a whole-disk filesystem with no partition table is NOT blank
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.8907101Z ok 439 skips nbd/loop/sr/zram even when they look empty
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.9243936Z ok 440 picks the first blank disk when several exist
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.9551619Z ok 441 no disks at all fails closed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:22.9909430Z ok 442 a blank disk listed AFTER the Windows disk is still found
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:23.3207999Z ok 443 manifest discovers categories from prefilled profile
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:23.3571901Z ok 444 VS Code configuration is preserved in prefilled profile
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:23.3945257Z ok 445 MS Office custom dictionary and templates are present in profile
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:23.4524729Z ok 446 registry programs.json includes typical apps
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:24.6288663Z ok 447 the reconnect cycle reports a channel that came back
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:25.7175360Z ok 448 the reconnect cycle claims nothing when the channel stays deaf
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:27.8316200Z ok 449 the recovery path cannot itself kill the run under set -e
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.0694517Z ok 450 the verdict lands in BOTH the ledger and the machine-readable file
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.1505961Z ok 451 channel loss is classified, not waited out
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.2318238Z ok 452 the reconnect cycle runs ONCE, and only before the verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.3149682Z ok 453 the reconnect cycle reaps stale clients before reopening
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.3945097Z ok 454 the verdict names the class and claims nothing about the product
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.5076411Z ok 455 the GUI timeout never treats live ping as a product verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.5871576Z ok 456 a channel that comes back is not a failure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.6665685Z ok 457 qga.py: a socket that will not open is TRANSPORT, not a guest exit code
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:28.7576954Z ok 458 qga.py: the reconnect cycle drains before it syncs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:37.7018682Z ok 459 the synthetic channel-kill test exists and runs in the fast tier
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:37.8252438Z ok 460 the staged agent is reachable from the booted root, not from /usr
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:37.8924047Z ok 461 the unit's ExecStart points at the file that was actually staged
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:37.9567138Z ok 462 the fallback stands down when the image ships its own agent
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.0083825Z ok 463 an unstageable closure fails loudly and leaves nothing half-installed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.0338233Z ok 464 the deployer no longer installs the agent into the shadowed /usr
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.0561907Z ok 465 the real-root stager is called from the post-install stage
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.1853730Z ok 466 all scripts are syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.2112775Z ok 467 Windows creates a PREALLOCATED raw image, not a VHDX
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.2352724Z ok 468 the image is physically allocated to prevent ntfs3 sparse I/O errors
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.2554319Z ok 469 the created size is verified, not assumed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.2758124Z ok 470 the deployer attaches with losetup --partscan
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.2996931Z ok 471 --partscan is present everywhere a loop device is attached
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.3222007Z ok 472 loop.max_part is set on the Phase-2 kernel cmdline, not just via modprobe
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.3435354Z ok 473 the verify attach is guarded like the main attach
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.3693262Z ok 474 detach uses losetup -d, not qemu-nbd --disconnect
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.3920677Z ok 475 no qemu-nbd remains in deployer CODE
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.4161490Z ok 476 the closure staging is gone entirely
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.4396325Z ok 477 the VHDX format check is gone
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.4602196Z ok 478 the boot hook attaches with losetup --partscan
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.4820860Z ok 479 the hook's failure message names losetup and the loop module
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.5062652Z ok 480 the hook calls no modprobe at all (Secure Boot lockdown), and never nbd
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.5267909Z ok 481 post-attach partitions are reported from the actual loop device
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.5488179Z ok 482 the attach mechanism is a systemd SERVICE, not just an initqueue hook
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.5772888Z ok 483 Phase-2 prefers the kernel ntfs3 mount and preserves FUSE fallback
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.5998103Z ok 484 the service is ordered before sysroot.mount and after udev
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.6217274Z ok 485 the service is WIRED into initrd-root-device.target, verified at build
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.6418931Z ok 486 module-setup falls back when systemdsystemunitdir is empty
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.6622122Z ok 487 the module depends on systemd so the unit dir exists
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.6863803Z ok 488 the initramfs guard checks the WIRED service, not the hook
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.7083509Z ok 489 the module installs losetup and the loop kernel module
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.7308317Z ok 490 the module stages NO foreign binary
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.7603589Z ok 491 the host-NTFS wait is wall-clock with an unconditional sleep
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.7845956Z ok 492 the EXIT device inventory cannot fall back to listing a directory
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.8522507Z ok 493 deploy.sh contains state writes for deploying, deployed, and failed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.8722949Z ok 494 deploy.sh contains fault injection hooks
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.8921352Z ok 495 setup-wootc.ps1 registers scheduled tasks and writes armed.json
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.9104349Z ok 496 app writes armed.json and registers recovery tasks in configureBCD
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.9288963Z ok 497 app unregisters recovery tasks on disarm and uninstall
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.9458187Z ok 498 uninstall_check asserts recovery scheduled tasks are unregistered
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:38.9683873Z ok 499 decision table covers all five verdicts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.0530400Z ok 500 matrix.tsv covers all 6 fault injection boundaries
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.0808095Z ok 501 matrix.tsv smoke tier includes critical recovery cells
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.0995804Z ok 502 run-matrix.sh threads fault= opts to --fault-inject and env var
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.1214610Z ok 503 run-e2e.sh accepts --fault-inject flag and handles recovery check
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.1460101Z ok 504 setup-wootc.ps1 supports -FaultInject parameter for all boundaries
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.1649849Z ok 505 setup-wootc.ps1 sweeps stale BCD entries for retry idempotency
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.1853475Z ok 506 deploy.sh reads wootc.fault and records failed state
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.2084283Z ok 507 assert-recovery.ps1 validates interrupted, retried, and uninstalled stages
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.2304146Z ok 508 run-recovery-matrix.sh is executable and accepts parameters
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.2508049Z ok 509 e2e-hosted.yml and e2e-matrix.yml support fault_inject
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.3068068Z ok 510 full releases hand the tag to winget-publish themselves
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.3783534Z ok 511 deploy.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.3969160Z ok 512 a dirty NTFS volume is still mountable via ntfs3 -o force
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.4254282Z ok 513 the plain read-only mount is still tried first
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.4448515Z ok 514 ntfs-3g is the last resort when the kernel driver is absent
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.4632313Z ok 515 an unmountable device is logged, never skipped silently
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.4818783Z ok 516 a mounted device without the vhdx is also logged
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.5001465Z ok 517 success reports which driver worked
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.5223502Z ok 518 the scan still fails closed when nothing carries the vhdx
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.6025585Z ok 519 the release stages a DUAL-signed shim, pinned by exact NVR
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.6236220Z ok 520 the build refuses to publish a shim that 2023-only firmware cannot launch
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.6542632Z ok 521 the exe is stamped from the extracted shim, never from a second copy of the pin
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.6753957Z ok 522 MokManager is NOT graded against the Microsoft CAs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.6983538Z ok 523 the harness proves the same shim the release ships
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.7472198Z ok 524 the extractor walks EVERY signature, not just the first
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.7698648Z ok 525 the extractor matches on the ISSUER, not the leaf subject
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.7953447Z ok 526 the preflight gates BEFORE anything is written, and only on a known mismatch
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.8186430Z ok 527 the db read is Windows-only and the dev stub gates nothing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.9159206Z ok 528 no selection file at all → everything migrates (default on)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:39.9865418Z ok 529 category explicitly ON → migrates
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.0569353Z ok 530 category explicitly OFF → skipped (this is the bug that was unwired)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.1287693Z ok 531 turning one category off does not disable the others
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.1782135Z ok 532 unreadable/corrupt selection fails open (never blocks a migration)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.3400014Z ok 533 the GUI's own default selection keeps every discovered category on
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.4411240Z ok 534 an explicitly disabled item is skipped while sibling items stay on
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.5151179Z ok 535 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.5364081Z ok 536 snapshot_serial reports failure rather than swallowing it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.5550778Z ok 537 a sustained dead feed produces a warning
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.5736962Z ok 538 the dead-feed warning states that downstream checks are untrustworthy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.5938503Z ok 539 consecutive failures are counted, and success resets the counter
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.6139840Z ok 540 the warning fires once, not on every poll
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.6395146Z ok 541 a stale PTY from a previous run is deleted before monitoring begins
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.6583645Z ok 542 the PTY-missing failure names the source it could not read
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.7418029Z ok 543 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.7610934Z ok 544 silence is measured against the serial file, not a counter
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.7815068Z ok 545 the silence threshold is generous enough for bootc install
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.8074263Z ok 546 guest CPU is sampled before any wedged verdict
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.8263015Z ok 547 high CPU during silence is reported as WORKING, not warned about
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.8449388Z ok 548 low CPU during silence is called out as wedged
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.8638365Z ok 549 a missing QEMU process is distinguished from a slow one
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.8826968Z ok 550 the CPU threshold is a real comparison, not a string test
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.9014513Z ok 551 the threshold is overridable for slow or busy hosts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.9252177Z ok 552 quiet-deploy heartbeat samples fisherman inside the guest through QGA
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.9455126Z ok 553 heartbeat is bounded and its thresholds are overridable
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.9682598Z ok 554 heartbeat cannot become success evidence or an automatic restart
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:40.9892247Z ok 555 unchanged workload counters require repeated samples before warning
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.0624025Z ok 556 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.0814597Z ok 557 the snapshot is disabled by default
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.1060471Z ok 558 the skip path still releases the OEM barrier
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.1302166Z ok 559 the skip happens BEFORE any freeze or copy
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.1487206Z ok 560 the skip is announced, not silent
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.1672569Z ok 561 it can still be re-enabled explicitly
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.1868683Z ok 562 there is still no restore path — the snapshot remains write-only
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.2467544Z ok 563 missing status CLI fails before VM setup
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.2802648Z ok 564 CLI checksum matches staged bytes and deleting binary fails again
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.3090667Z ok 565 guest checks CLI before partitioning and BCD arming
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.4079600Z ok 566 all generated step IDs, owners and labels are current
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:41.4467990Z ok 567 every phase the deployer announces is catalogued with the correct owner
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.4482747Z ok 568 real splash, firstboot and harness consumers reject label and stale-output mutations
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.5193767Z ok 569 the uninstall stage exists, opt-in, after Windows verifiably returned
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.5542096Z ok 570 every cleanup claim is asserted separately, and data preservation is one of them
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.5742645Z ok 571 the workflow can request the uninstall stage
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.6448531Z ok 572 getUninstallInfo detects partial install when wootc directory exists without root.disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.6634909Z ok 573 uninstallWith cleans up install, bundle, cache, and logs dirs on keep-root.disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.6806148Z ok 574 uninstallWith sweeps entire wootc folder when no root.disk exists or on deletion request
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.7052335Z ok 575 dedicated volume verification uses receipt policy and accepts only non-system basic NTFS partitions
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.7253812Z ok 576 partition reclaim refuses C and consumes only a receipt-bound target
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.7440397Z ok 577 uninstall verifies clean convergence and reports leftover artifacts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.7641367Z ok 578 headless uninstall command parses -delete-root-disk and -remove-partition flags
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.8488791Z ok 579 the power record is read BEFORE the folder holding it is removed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.8843122Z ok 580 the pre-install power state is mirrored where a hand-deleted folder cannot take it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.9087462Z ok 581 restoration re-enables only what was on, and reads values not substrings
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.9319072Z ok 582 only the FIRST install defines what 'prior' means
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.9527088Z ok 583 the field verifier exists and refuses to grade without a baseline
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.9753670Z ok 584 the verifier grades keep and delete against different end states
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:42.9982857Z ok 585 a missing power baseline fails the box instead of skipping it
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.0204590Z ok 586 the ESP check is two-sided: ours gone AND nothing else touched
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.0448834Z ok 587 the checklist cannot be talked into passing
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.0643714Z ok 588 the PowerShell grading logic is actually run by the fast tier
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.0895170Z ok 589 manual-testing documents the procedure the issue asks for
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.2057270Z ok 590 self-test passes
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.2548291Z ok 591 identity fields are pre-filled from the scan
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.3042494Z ok 592 prefill never carries a password field through
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.3527122Z ok 593 the password explanation is never blank
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.4013484Z ok 594 valid usernames are accepted, invalid ones explained
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.4497960Z ok 595 reserved system usernames are refused
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.4988007Z ok 596 passwords must meet the length floor and match their confirmation
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.5475157Z ok 597 validate reports every problem at once, not one at a time
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.6007313Z ok 598 the password NEVER reaches the saved account file
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.6503293Z ok 599 a future field added to the form cannot leak into the saved record
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.7018402Z ok 600 the password goes to chpasswd on stdin, never in argv
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.7510294Z ok 601 a chpasswd failure is surfaced, not silently swallowed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.8003463Z ok 602 set_password refuses empty credentials
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.8779338Z ok 603 the arm script vaults a Linux user matching the Windows profile name
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.9028110Z ok 604 user data is seeded before the deployer barrier is released
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.9267169Z ok 605 Phase-2 bridge check is a live QGA content read against RUN_ID
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.9526314Z ok 606 Phase-3 native persistence check reads the file from the native disk
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:43.9757036Z ok 607 vault user requests only the wheel group
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.0063647Z ok 608 passthrough is wanted by multi-user, after /var is mounted
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.0314707Z ok 609 Phase-3 reboot never rides the QGA channel
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.0523008Z ok 610 mount-user-dirs reports a bind verdict and flags a missing home
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.0745793Z ok 611 fisherman pins user homes into the stateroot var
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.0959298Z ok 612 emergency-shell diagnosis distinguishes attach from mount failure
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.2248986Z ok 613 WPA2-PSK profile becomes a wpa-psk NM keyfile with the passphrase
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.2925688Z ok 614 WPA3-SAE profile maps to key-mgmt=sae
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.3610439Z ok 615 open network imports with no wifi-security section
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.4225090Z ok 616 enterprise 802.1X profile is DETECTED but never copied
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.4828828Z ok 617 PSK profile with no cleartext key is skipped, not written keyless
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.5485566Z ok 618 the transient cleartext export is destroyed after import
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.6495270Z ok 619 state json records the imported/skipped counts for the dashboard
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.7153011Z ok 620 an existing NM connection is not clobbered
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.7436964Z ok 621 empty export dir is a clean no-op
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.8315711Z ok 622 picks Pro without matching Pro N or Pro Education
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.8605948Z ok 623 picks LTSC, which spells itself Enterprise LTSC
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.8894871Z ok 624 plain Enterprise never resolves to the LTSC image
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.9219921Z ok 625 an ambiguous list yields nothing rather than a coin flip
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.9513410Z ok 626 no match yields nothing, leaving Setup to decide
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:44.9788980Z ok 627 an unknown edition never guesses
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.0076922Z ok 628 surrounding whitespace in wiminfo output is tolerated
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.0403017Z ok 629 the in-run heal only fires when it can add information
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.0705196Z ok 630 the in-run heal waits for the download to settle before reading
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.1007176Z ok 631 the in-run heal never proceeds on an ambiguous list
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.1956820Z ok 632 run-e2e.sh is syntactically valid
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.2209035Z ok 633 the capture point is BEFORE any OEM/migration setup runs
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.2442959Z ok 634 prime captures via a CLEAN shutdown, never a live fsfreeze
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.2637776Z ok 635 prime waits for the guest to power off before converting
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.2886738Z ok 636 prime brings the container down before qemu-img convert
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.3081460Z ok 637 prime produces a compressed, standalone qcow2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.3277981Z ok 638 prime refuses to combine with --skip-install
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.3501978Z ok 639 prime writes the correctness key AND dockur's install markers
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.3756388Z ok 640 restore keys on the SAME formula as ANSWER_SHA (folds in bitlocker)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.4012087Z ok 641 a key MISMATCH falls back to a full install, never fails the run
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.4278699Z ok 642 a successful restore forces the --skip-install reuse path
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.4488175Z ok 643 the hosted E2E pull is best-effort (a cache miss never gates a run)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.4726304Z ok 644 the prime workflow pushes to a per-axis GHCR tag via ORAS
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.4922282Z ok 645 the hosted workflow has packages:read to pull the base image
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.6961104Z ── python unit tests (tests/unit/test_*.py) ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.6962331Z tests/unit/test_brand_adoption.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.9975204Z ....
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.9975809Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.9976433Z Ran 4 tests in 0.249s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.9976658Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:45.9976766Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.0065231Z tests/unit/test_convert_directory_swap.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5482198Z .....
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5482821Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5483383Z Ran 5 tests in 0.496s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5483589Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5483708Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:46.5574129Z tests/unit/test_coverage_report.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1147556Z .........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1148324Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1148923Z Ran 9 tests in 0.506s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1149137Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1149252Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1243846Z tests/unit/test_deployer_splash.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1985476Z ....
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1985953Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1986475Z Ran 4 tests in 0.029s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1986710Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.1986821Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:28:47.2066328Z tests/unit/test_e2e_adapters.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9578626Z ................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9579474Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9580099Z Ran 16 tests in 18.702s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9580338Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9580474Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:05.9671907Z tests/unit/test_e2e_results.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:09.0816569Z ........[FAIL] Result evidence rejected: [Errno 17] File exists: '/tmp/tmpmyk84bo2/video/.passed.nln0ewnv' -> '/tmp/tmpmyk84bo2/video/.passed'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4787098Z ..........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4787914Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4788374Z Ran 18 tests in 5.427s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4788548Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4788640Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.4941191Z tests/unit/test_esp_chain_refresh.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.5937834Z test_a_refused_candidate_leaves_the_working_chain_in_place (__main__.ChainRefresh.test_a_refused_candidate_leaves_the_working_chain_in_place) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.6699736Z test_another_linuxs_vendor_directory_on_the_same_esp_is_never_touched (__main__.ChainRefresh.test_another_linuxs_vendor_directory_on_the_same_esp_is_never_touched) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.7079489Z test_identical_chain_is_a_no_op (__main__.ChainRefresh.test_identical_chain_is_a_no_op) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.7392146Z test_no_helper_means_the_chain_is_left_alone (__main__.ChainRefresh.test_no_helper_means_the_chain_is_left_alone) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.7440819Z test_no_wootc_owned_directory_means_nothing_is_touched (__main__.ChainRefresh.test_no_wootc_owned_directory_means_nothing_is_touched) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.8218648Z test_refreshes_all_three_when_the_candidate_is_accepted (__main__.ChainRefresh.test_refreshes_all_three_when_the_candidate_is_accepted) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.8941426Z test_shim_is_written_last (__main__.ChainRefresh.test_shim_is_written_last) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9664189Z test_the_previous_chain_is_archived_before_replacement (__main__.ChainRefresh.test_the_previous_chain_is_archived_before_replacement) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9665237Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9665531Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9666080Z Ran 8 tests in 0.426s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9666287Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9666400Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:11.9755828Z tests/unit/test_gui_drive_receipt.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4244311Z ..E.......
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4244856Z ======================================================================
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4246305Z ERROR: test_actual_frontend_produces_bound_install_and_refuses_changed_identity (__main__.GuiDriveReceiptTests.test_actual_frontend_produces_bound_install_and_refuses_changed_identity)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4247904Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4248462Z Traceback (most recent call last):
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4249731Z File "/home/runner/work/wootc/wootc/tests/unit/test_gui_drive_receipt.py", line 57, in test_actual_frontend_produces_bound_install_and_refuses_changed_identity
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4251211Z receipt = json.loads(module.validate(raw, 'current', DIRECTIVE, IMAGE))
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4252455Z ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4253292Z File "/home/runner/work/wootc/wootc/tests/e2e/gui-drive-receipt.py", line 30, in validate
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4254085Z raise ValueError('invalid GUI fields')
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4254533Z ValueError: invalid GUI fields
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4254792Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4255043Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4255562Z Ran 10 tests in 0.399s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4255762Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4255888Z FAILED (errors=1)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:12.4341421Z tests/unit/test_gui_observations.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8587468Z ..............
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8588493Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8589077Z Ran 14 tests in 9.376s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8589296Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8589420Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:21.8684631Z tests/unit/test_gui_session.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8389193Z ................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8389701Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8390212Z Ran 16 tests in 2.922s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8390412Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8390527Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:24.8490452Z tests/unit/test_native_userdata_export.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1136508Z ............
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1137154Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1138093Z Ran 12 tests in 0.219s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1138352Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1138486Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.1232189Z tests/unit/test_optical_media_control.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.6983514Z ..........s.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.6984463Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.6985338Z Ran 12 tests in 0.522s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.6985792Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.6986203Z OK (skipped=1)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:25.7072650Z tests/unit/test_phase3_observations.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0764554Z .................................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0765276Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0765898Z Ran 33 tests in 3.317s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0766123Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0766234Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:29.0875017Z tests/unit/test_plugin_import_results.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5286582Z .
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5287232Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5288132Z Ran 1 test in 1.394s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5288386Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5288532Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:30.5382170Z tests/unit/test_plugin_manifest.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5849906Z .......
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5850587Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5851621Z Ran 7 tests in 1.999s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5851885Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5852027Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.5945317Z tests/unit/test_prefilled_profile.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.6347971Z PASS (14 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:32.6397587Z tests/unit/test_qga_reconnect.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:39.4904643Z QGA: cannot open /tmp/qga-channel-kill-bga2e5hk/not-here.sock: [Errno 2] No such file or directory
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:40.4934081Z QGA reconnect: attempt 1/2: timed out
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.4950846Z QGA reconnect: attempt 2/2: timed out
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.4952868Z QGA: channel still deaf after 2 reconnect attempts — qga-channel-lost
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.4957002Z PASS (10 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5026090Z tests/unit/test_qga_write.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5463766Z PASS (3 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5524802Z tests/unit/test_recovery_guard.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5962715Z ........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5963272Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5963935Z Ran 8 tests in 0.002s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5964181Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.5964317Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6036036Z tests/unit/test_recovery_matrix.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6498638Z ....
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6499181Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6499741Z Ran 4 tests in 0.002s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6499956Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6500082Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:41.6564951Z tests/unit/test_safe_diagnostics.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3257962Z .........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3258616Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3259147Z Ran 9 tests in 0.620s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3259331Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3259429Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3348151Z tests/unit/test_shim_trust.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3852800Z test_accepts_when_the_authorities_intersect (__main__.Decision.test_accepts_when_the_authorities_intersect) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3855978Z test_refuses_a_candidate_the_firmware_does_not_trust (__main__.Decision.test_refuses_a_candidate_the_firmware_does_not_trust) ... [wootc-shim-trust] refusing cand.efi: signed by 2023, firmware trusts 2011. Installing it would stop this machine booting Linux.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3858253Z ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3860073Z test_refuses_a_candidate_with_no_microsoft_signature (__main__.Decision.test_refuses_a_candidate_with_no_microsoft_signature) ... [wootc-shim-trust] refusing cand.efi: no Microsoft UEFI CA signature. The firmware would not launch it.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3862209Z ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3864814Z test_refuses_when_secure_boot_is_on_and_the_db_is_unreadable (__main__.Decision.test_refuses_when_secure_boot_is_on_and_the_db_is_unreadable) ... [wootc-shim-trust] refusing cand.efi: Secure Boot is on and the firmware's trust store could not be read. The chain that boots today stays.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3866305Z ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3867711Z test_secure_boot_off_skips_the_trust_check_but_not_sbat (__main__.Decision.test_secure_boot_off_skips_the_trust_check_but_not_sbat) ... [wootc-shim-trust] refusing cand.efi: SBAT generation 3 is older than the installed 7. A downgrade is what revocation blocks.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3869015Z ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3869674Z test_a_non_pe_file_raises_rather_than_guessing (__main__.ParserSafety.test_a_non_pe_file_raises_rather_than_guessing) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3870798Z test_an_unsigned_pe_reports_no_authorities (__main__.ParserSafety.test_an_unsigned_pe_reports_no_authorities) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3871893Z test_no_efivarfs_means_unknown_not_untrusted (__main__.ParserSafety.test_no_efivarfs_means_unknown_not_untrusted) ... ok
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3873445Z test_reads_a_dual_signed_shim_as_dual_signed (__main__.RealBinaries.test_reads_a_dual_signed_shim_as_dual_signed) ... skipped 'no real shim fixtures available'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3875004Z test_reads_the_sbat_generation_from_the_sbat_section (__main__.RealBinaries.test_reads_the_sbat_generation_from_the_sbat_section) ... skipped 'no real shim fixtures available'
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3875931Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3876129Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3876535Z Ran 10 tests in 0.001s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3876703Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3876807Z OK (skipped=2)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3877064Z ok cand.efi cas=2011,2023 sbat=4
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.3932724Z tests/unit/test_soak_ledger.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4661115Z .............
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4661743Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4662297Z Ran 13 tests in 0.014s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4662571Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4662681Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:42.4744774Z tests/unit/test_step_catalogue.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4734997Z ........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4735595Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4736155Z Ran 8 tests in 0.951s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4736373Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4736490Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.4823699Z tests/unit/test_uninstall_partial.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5003159Z ── test_uninstall_partial: verifying partial-state uninstall convergence ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5004352Z [PASS] getUninstallInfo checks BCD firmware entries
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5005247Z [PASS] getUninstallInfo checks ESP artifacts
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5006158Z [PASS] getUninstallInfo checks Add/Remove registry key
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5007149Z [PASS] uninstallWith invokes ownership-aware cleanupESP
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5008283Z [PASS] uninstallWith verifies clean convergence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5008946Z [PASS] uninstallWith sweeps BCD entries
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5009545Z [PASS] uninstallWith clears one-shot bootsequence
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5010430Z [PASS] uninstallWith restores power state before directory removal
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5011514Z [PASS] assessment requires protected persistent creation receipt
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5012608Z [PASS] actual removal uses full identity and contents binding
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5013738Z [PASS] removal consumes verified object rather than drive letter
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5014832Z [PASS] backend requires positively observed deletion and extension
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5015855Z [PASS] explicit removal intent survives partial uninstall
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5016567Z [PASS] headless CLI dispatches to headlessUninstall
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5017257Z [PASS] headlessUninstall supports -delete-root-disk flag
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5018188Z [PASS] headlessUninstall supports -remove-partition flag
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5019132Z [PASS] sysprobe_windows.go supports reading power state from registry mirror
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5020129Z [PASS] sysprobe_windows.go mirrors WootcPriorHibernate in registry
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5021046Z [PASS] sysprobe_windows.go mirrors WootcPriorHiberboot in registry
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5021549Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5021713Z ALL CHECKS PASSED
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.5039977Z tests/unit/test_vm_first_host.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6147124Z ..............
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6148205Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6149110Z Ran 14 tests in 0.038s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6149372Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6149584Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.6291222Z tests/unit/test_vm_helper.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8095108Z .......
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8095742Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8096411Z Ran 7 tests in 0.135s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8096667Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8096811Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.8184534Z tests/unit/test_vm_runtime_bundle.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9000232Z ...
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9001319Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9001897Z Ran 3 tests in 0.016s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9002120Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9002240Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:29:43.9102619Z tests/unit/test_vm_start.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3191109Z ...................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3191725Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3192624Z Ran 19 tests in 17.359s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3192838Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3192959Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.3277089Z tests/unit/test_windows_resources.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.4164284Z go: downloading github.com/tc-hib/go-winres v0.3.3
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.8315747Z go: downloading github.com/tc-hib/winres v0.2.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:01.8321694Z go: downloading github.com/urfave/cli/v2 v2.25.7
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:02.1116286Z go: downloading github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:02.1131553Z go: downloading golang.org/x/image v0.12.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:02.6207823Z go: downloading github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:02.6208751Z go: downloading github.com/cpuguy83/go-md2man/v2 v2.0.2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:02.7430767Z go: downloading github.com/russross/blackfriday/v2 v2.1.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7607241Z ...
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7608040Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7608569Z Ran 3 tests in 6.382s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7608765Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7608863Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:07.7703713Z tests/unit/test_windows_restart.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.0993406Z .....................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.0994089Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.0994654Z Ran 21 tests in 7.280s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.0994864Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.0994983Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1088747Z tests/unit/test_winget_brand.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1678894Z .....
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1679394Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1680005Z Ran 5 tests in 0.012s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1680262Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1680398Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.1743492Z tests/unit/test_wootc_package_consumer.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9782979Z ............................
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9783676Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9784240Z Ran 28 tests in 0.728s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9784502Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9784615Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:15.9925899Z tests/unit/test_workflow_safety.py
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0855474Z ........
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0856012Z ----------------------------------------------------------------------
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0856564Z Ran 8 tests in 0.046s
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0856774Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0856883Z OK
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:16.0943932Z ── powershell lint (tests/lint-ps1.ps1) ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.4493308Z ok app/partition-policy.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.4764845Z ok app/partition-policy.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.4854690Z ok docs/experiments/evidence/2026-09-26-vm-first/windows-whpx/wootc-phase1-bootsector-run.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.4920437Z ok docs/experiments/evidence/2026-09-26-vm-first/windows-whpx/wootc-phase1-disk-probe-run300.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.4985704Z ok docs/experiments/evidence/2026-09-26-vm-first/windows-whpx/wootc-phase1-disk-probe-run900.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5052934Z ok docs/experiments/evidence/2026-09-26-vm-first/windows-whpx/wootc-phase1-probe-run-userspaceirq.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5118804Z ok docs/experiments/evidence/2026-09-26-vm-first/windows-whpx/wootc-phase1-probe-run.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5186019Z ok docs/experiments/evidence/2026-09-26-vm-runtime/gtk-interactive-probe.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5251456Z ok docs/experiments/evidence/2026-09-26-vm-runtime/gtk-session0-probe.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5293073Z ok docs/experiments/evidence/2026-09-26-vm-runtime/gtk-task.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5362728Z ok docs/experiments/evidence/2026-09-26-vm-runtime/runtime-probe.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5384306Z ok docs/experiments/evidence/2026-09-26-vm-runtime/windows-install.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5433213Z ok docs/experiments/evidence/2026-09-27-artifact-auth/native-windows.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5525565Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5796447Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/fixture-bitlocker-diagnostics.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.5932747Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/fixture-bitlocker-key.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6058044Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/fixture-bitlocker-key.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6371210Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/fixture-bitlocker-protection.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6549618Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/fixture-bitlocker-protection.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6636668Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/failed-direct-sources/lint-ps1.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6682437Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/hostless-probe.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6775306Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6880280Z ok docs/experiments/evidence/2026-09-27-bitlocker-activation-diagnostics/prior-ancestor-mask-native/native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.6904352Z ok docs/experiments/evidence/2026-09-27-drive-receipt/native/install-command.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7006507Z ok docs/experiments/evidence/2026-09-27-drive-receipt/native/invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7028454Z ok docs/experiments/evidence/2026-09-27-drive-receipt/native/reboot-command.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7106233Z ok docs/experiments/evidence/2026-09-27-gui-handover/native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7190471Z ok docs/experiments/evidence/2026-09-27-gui-session/native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7288151Z ok docs/experiments/evidence/2026-09-27-storage-root/wootc-storage-query-native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7324830Z ok docs/experiments/evidence/2026-09-27-windows-restart/boot-query.literal.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7343918Z ok docs/experiments/evidence/2026-09-27-windows-restart/restart-request.literal.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7491187Z ok docs/experiments/evidence/2026-09-27-windows-restart/wootc-restart-native-invocation.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7538279Z ok packaging/winget/verified-tool.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.7687478Z ok tests/e2e/assert-recovery.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.8086698Z ok tests/e2e/fixture-bitlocker-diagnostics.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.8458322Z ok tests/e2e/fixture-bitlocker-key.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.8575535Z ok tests/e2e/fixture-bitlocker-key.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.8744655Z ok tests/e2e/fixture-bitlocker-protection.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.8907851Z ok tests/e2e/fixture-bitlocker-protection.tests.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9012419Z ok tests/e2e/oem/run-wootc-e2e.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9179838Z ok tests/e2e/oem/seed-profile.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9307304Z ok tests/e2e/phase1/assert-phase1.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9486899Z ok tests/e2e/seed-profile.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9890698Z ok tests/e2e/setup-wootc.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:17.9957870Z ok tests/e2e/stage-status-cli.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0044784Z ok tests/e2e/state-trust.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0104911Z ok tests/e2e/test-stage-status-cli.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0224589Z ok tests/e2e/test-state-trust.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0248315Z ok tests/e2e/windows-storage-root.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0529473Z ok tests/field/verify-fresh-machine.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0871493Z ok tests/field/verify-uninstall.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.0955473Z ok tests/lint-ps1.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1038266Z ok tests/unit/test-verified-winget-tool.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1231154Z ok tests/unit/test-verify-fresh-machine.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1395400Z ok tests/unit/test-verify-uninstall.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1402619Z PowerShell lint passed
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1916081Z ── powershell unit tests (tests/unit/test-*.ps1) ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:18.1916983Z tests/unit/test-verified-winget-tool.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:19.0443871Z PASS (8 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:19.0815430Z tests/unit/test-verify-fresh-machine.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:19.9513347Z PASS (54 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:19.9904158Z tests/unit/test-verify-uninstall.ps1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:20.8379625Z PASS (34 checks)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:20.8775409Z ── go test (fisherman TUI, cross-platform app) ──
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:20.8791042Z (created placeholder app/frontend/dist for the go:embed)
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:20.9134868Z go: downloading github.com/wailsapp/wails/v2 v2.16.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.3082720Z go: downloading golang.org/x/crypto v0.57.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.3083679Z go: downloading golang.org/x/mod v0.41.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.3458947Z go: downloading github.com/leaanthony/slicer v1.6.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.3464779Z go: downloading github.com/leaanthony/u v1.1.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.3655328Z go: downloading github.com/leaanthony/go-ansi-parser v1.6.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.4231067Z go: downloading github.com/pkg/errors v0.9.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:21.5190474Z go: downloading github.com/rivo/uniseg v0.4.7
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6028918Z ok wootc 5.905s coverage: 66.6% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6029934Z ok wootc/internal/artifactauth 0.014s coverage: 96.2% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6030748Z ok wootc/internal/runtimebundle 0.037s coverage: 82.9% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6031477Z ok wootc/tools/gendto 0.006s coverage: 86.7% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6032200Z ok wootc/tools/signmanifest 0.008s coverage: 69.8% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6589161Z go: downloading github.com/charmbracelet/bubbletea v1.3.10
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6589979Z go: downloading github.com/charmbracelet/bubbles v1.0.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.6590933Z go: downloading github.com/charmbracelet/huh v1.0.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7042485Z go: downloading github.com/charmbracelet/lipgloss v1.1.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7222162Z go: downloading github.com/charmbracelet/x/ansi v0.11.6
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7223059Z go: downloading github.com/charmbracelet/x/term v0.2.2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7224295Z go: downloading github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7325200Z go: downloading github.com/muesli/cancelreader v0.2.2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7449793Z go: downloading github.com/charmbracelet/harmonica v0.2.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7558073Z go: downloading github.com/lucasb-eyer/go-colorful v1.3.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7769226Z go: downloading github.com/muesli/termenv v0.16.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7876373Z go: downloading github.com/catppuccin/go v0.3.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.7877090Z go: downloading github.com/charmbracelet/x/cellbuf v0.0.15
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8085450Z go: downloading github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8168425Z go: downloading github.com/mitchellh/hashstructure/v2 v2.0.2
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8202212Z go: downloading golang.org/x/sys v0.38.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8466903Z go: downloading github.com/clipperhouse/displaywidth v0.9.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8648936Z go: downloading github.com/clipperhouse/uax29/v2 v2.5.0
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.8997149Z go: downloading github.com/mattn/go-runewidth v0.0.19
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9167021Z go: downloading github.com/dustin/go-humanize v1.0.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9304368Z go: downloading github.com/atotto/clipboard v0.1.4
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9403913Z go: downloading github.com/aymanbagabas/go-osc52/v2 v2.0.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9503297Z go: downloading github.com/mattn/go-isatty v0.0.20
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9525245Z go: downloading github.com/charmbracelet/colorprofile v0.4.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9607096Z go: downloading github.com/clipperhouse/stringish v0.1.1
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:38.9656547Z go: downloading github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1055435Z github.com/projectbluefin/fisherman/tui/cmd/bootc-installer-tui coverage: 0.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1056578Z ok github.com/projectbluefin/fisherman/tui/internal/config 0.012s coverage: 90.5% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1057280Z ok github.com/projectbluefin/fisherman/tui/internal/disks 0.004s coverage: 38.9% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1064165Z github.com/projectbluefin/fisherman/tui/internal/network coverage: 0.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1065963Z ok github.com/projectbluefin/fisherman/tui/internal/sshkeys 0.022s coverage: 81.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:43.1157351Z github.com/projectbluefin/fisherman/tui/internal/tui coverage: 0.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:44.1461121Z ok github.com/tuna-os/fisherman/cmd/fisherman 0.018s coverage: 10.6% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:44.3259208Z github.com/tuna-os/fisherman/cmd/validate-overlay coverage: 0.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:47.1126544Z ok github.com/tuna-os/fisherman/internal/disk 2.957s coverage: 36.3% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:47.1127425Z github.com/tuna-os/fisherman/internal/images coverage: 0.0% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1650409Z ok github.com/tuna-os/fisherman/internal/install 3.949s coverage: 60.9% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1651681Z ok github.com/tuna-os/fisherman/internal/luks 0.004s coverage: 75.6% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1652827Z ok github.com/tuna-os/fisherman/internal/post 0.085s coverage: 52.2% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1653957Z ok github.com/tuna-os/fisherman/internal/progress 0.003s coverage: 63.6% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1655120Z ok github.com/tuna-os/fisherman/internal/recipe 0.012s coverage: 82.5% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1656270Z ok github.com/tuna-os/fisherman/internal/runner 0.007s coverage: 87.2% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.1657398Z ok github.com/tuna-os/fisherman/internal/slurp 0.053s coverage: 92.2% of statements
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2422908Z ## Measured Go coverage
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2423285Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2423549Z | Module | Covered statements | Total | Percent |
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2424141Z |---|---:|---:|---:|
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2424440Z | app | 2719 | 3976 | 68.39% |
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2424743Z | fisherman-tui | 43 | 742 | 5.80% |
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2425092Z | fisherman-core | 1806 | 3838 | 47.06% |
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2425358Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2425480Z Project: 53.39% (minimum 45%).
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2425668Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2426187Z Go statements compiled by the current OS in all configured fast-tier modules. Windows-only code is not measured by a Linux run.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2427018Z A passing report does not mean every subprocess was instrumented.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2427365Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2427840Z Changed instrumented lines: 84.63% (informational target 70%).
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2428474Z A changed instrumented line counts only if every block on it was hit.
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2428826Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2476219Z
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2476970Z ✗ fast tier FAILED
+fast tier (bats + go) Run fast tier and enforce Go coverage 2026-09-27T15:30:48.2496342Z ##[error]Process completed with exit code 1.
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-provenance.json b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-provenance.json
new file mode 100644
index 00000000..f31832bb
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-fast-provenance.json
@@ -0,0 +1,17 @@
+{
+ "source": "c9b60777cfac4f721a25994b05c78a796ab63e2f",
+ "runId": 36329610437,
+ "jobId": 108648907878,
+ "cause": "install receipt contains VM preparation fields rejected by strict install host schema",
+ "sourceHashes": {
+ "tests/unit/test_gui_drive_receipt.py": "608e748fc759f68a4c79963cadccaf74786fe7a99fd730a1da6758a4ac22c2c2",
+ "tests/e2e/gui-drive-receipt.py": "b791144db47510eca70685c4c605af27e0d314f164e8993eb64f4eb012f8a337",
+ "app/frontend/src/lib/e2e.js": "02d55c01f2b4e60c1c246b972627186e14d8cab3479ccdc1ec9da0ab3eb6f059",
+ "tests/unit/gui-drive-producer-controls.cjs": "b7b5e8b09825a60767dcbbfe25770ac8096ce44969ef56e3abf7bda85039b710"
+ },
+ "evidenceHashes": {
+ "/tmp/wootc-495-c9-fast-failure.raw.log": "ca33984d448790a5ed085b55e3f0b38d2bf1190135c8dfcd09ef60bcc7846792",
+ "/tmp/wootc-495-c9-local-drive.raw.log": "75d6e8c072b009d83e639f13a2b59fd27965a6fffd3e67837712aa365cb471e6",
+ "/tmp/wootc-495-c9-drive-control.json": "049f4e894d0729f249592c8796281d205151c16c1a8332f23fe085939cc679db"
+ }
+}
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-local-drive.raw.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-local-drive.raw.log
new file mode 100644
index 00000000..301b7c58
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/receipt-schema/wootc-495-c9-local-drive.raw.log
@@ -0,0 +1,16 @@
+..E.......
+======================================================================
+ERROR: test_actual_frontend_produces_bound_install_and_refuses_changed_identity (__main__.GuiDriveReceiptTests.test_actual_frontend_produces_bound_install_and_refuses_changed_identity)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/tmp/wootc-vm-scoped-qmp/tests/unit/test_gui_drive_receipt.py", line 57, in test_actual_frontend_produces_bound_install_and_refuses_changed_identity
+ receipt = json.loads(module.validate(raw, 'current', DIRECTIVE, IMAGE))
+ ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/tmp/wootc-vm-scoped-qmp/tests/e2e/gui-drive-receipt.py", line 30, in validate
+ raise ValueError('invalid GUI fields')
+ValueError: invalid GUI fields
+
+----------------------------------------------------------------------
+Ran 10 tests in 0.451s
+
+FAILED (errors=1)
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/running-observation-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/running-observation-mutant.log
new file mode 100644
index 00000000..5bd536ec
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/running-observation-mutant.log
@@ -0,0 +1,18 @@
+=== RUN TestVMDisplayUnknownOrFailedObservations
+=== RUN TestVMDisplayUnknownOrFailedObservations/paused
+ vm_display_test.go:197: failed observation accepted
+=== RUN TestVMDisplayUnknownOrFailedObservations/fake-status
+=== RUN TestVMDisplayUnknownOrFailedObservations/ambiguous-mice
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-pixels
+=== RUN TestVMDisplayUnknownOrFailedObservations/missing-capture
+=== RUN TestVMDisplayUnknownOrFailedObservations/bad-key-ack
+--- FAIL: TestVMDisplayUnknownOrFailedObservations (0.17s)
+ --- FAIL: TestVMDisplayUnknownOrFailedObservations/paused (0.04s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/fake-status (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/ambiguous-mice (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-pixels (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/missing-capture (0.03s)
+ --- PASS: TestVMDisplayUnknownOrFailedObservations/bad-key-ack (0.03s)
+FAIL
+FAIL wootc 0.182s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-scoped-qmp/session-correlation-mutant.log b/docs/experiments/evidence/2026-09-27-scoped-qmp/session-correlation-mutant.log
new file mode 100644
index 00000000..9656fe4b
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-scoped-qmp/session-correlation-mutant.log
@@ -0,0 +1,6 @@
+=== RUN TestVMDisplayIdentityAndReplayRefuseBeforeGuest
+ vm_display_test.go:164: session accepted
+--- FAIL: TestVMDisplayIdentityAndReplayRefuseBeforeGuest (0.03s)
+FAIL
+FAIL wootc 0.044s
+FAIL
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/.gitattributes b/docs/experiments/evidence/2026-09-27-vm-current-integration/.gitattributes
new file mode 100644
index 00000000..817a7482
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/.gitattributes
@@ -0,0 +1 @@
+*.log -text
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/full-frontend-controls.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/full-frontend-controls.log
new file mode 100644
index 00000000..fd62a2cf
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/full-frontend-controls.log
@@ -0,0 +1,54 @@
+[WebServer] (node:3170940) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+[WebServer] (node:3170963) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+
+Running 43 tests using 1 worker
+
+(node:3170996) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✓ 1 gui.spec.js:30:1 › installer — launchpad (variant selection + system info) (1.1s)
+ ✓ 2 gui.spec.js:42:1 › installer — validation gates the Install button (535ms)
+ ✓ 3 gui.spec.js:58:1 › installer — progress screen (1.5s)
+ ✓ 4 gui.spec.js:72:1 › installer — done screen (866ms)
+ ✓ 5 gui.spec.js:93:1 › installer — session consent does not assert successful export (769ms)
+ ✓ 6 gui.spec.js:109:1 › control panel — partition-aware uninstall options (443ms)
+ ✓ 7 gui.spec.js:122:1 › control panel — personal partition without wootc-data label never offers RemovePartition (467ms)
+ ✓ 8 gui.spec.js:133:1 › control panel — uninstall confirmation names the verified volume label (332ms)
+ ✓ 9 gui.spec.js:149:1 › control panel — Boot in VM offered when available (§6.2) (267ms)
+ ✓ 10 gui.spec.js:158:1 › control panel — unavailable VM explains how to enable acceleration (259ms)
+ ✓ 11 gui.spec.js:167:1 › installer — NTFS defrag recommendation is advisory and actionable (§3.6) (402ms)
+ ✓ 12 gui.spec.js:177:1 › installer — backend detection is automatic; forcing systemd-boot warns for Secure Boot (441ms)
+ ✓ 13 gui.spec.js:192:1 › installer — supported family custom OCI reference is accepted (370ms)
+ ✓ 14 gui.spec.js:207:1 › installer — BitLocker offers unencrypted-partition path (no forced decrypt) (285ms)
+ ✓ 15 gui.spec.js:222:1 › installer — LUKS encryption options (§2.6) with TPM recommended (610ms)
+ ✓ 16 gui.spec.js:243:1 › branding — partner re-skin applies theme + copy (317ms)
+ ✓ 17 gui.spec.js:263:1 › branding — a brand build wears real assets, not the wootc name (332ms)
+ ✓ 18 gui.spec.js:299:1 › migration dashboard — files, apps, office (306ms)
+ ✓ 19 gui.spec.js:315:1 › installer — custom OCI ref defaults to auto backend detection (338ms)
+ ✓ 20 gui.spec.js:339:1 › installer — identity hides under Advanced only when derived from the PC (337ms)
+ ✓ 21 gui.spec.js:363:1 › installer — identity stays on the main form when it cannot be derived (258ms)
+ ✓ 22 gui.spec.js:375:1 › installer — the default form asks for a password and nothing else (481ms)
+ ✓ 23 gui.spec.js:410:1 › titlebar — frameless window controls reach the runtime (272ms)
+ ✓ 24 gui.spec.js:422:1 › titlebar — closing during an install asks for confirmation (413ms)
+ ✓ 25 gui.spec.js:449:1 › every exit button reaches the runtime (343ms)
+ ✓ 26 gui.spec.js:466:1 › disk size larger than C: can hold gates the install button (256ms)
+ ✓ 27 gui.spec.js:480:1 › a failed last run is acknowledged on relaunch (186ms)
+ ✓ 28 gui.spec.js:489:1 › a deployed install offers Restart into TunaOS (210ms)
+ ✓ 29 gui.spec.js:498:1 › new release notice preserves a form in use and opens the release page (1.8s)
+ ✓ 30 gui.spec.js:513:1 › release notice failure leaves the launchpad usable (294ms)
+ ✓ 31 gui.spec.js:521:1 › release notice rejects a non-release link (237ms)
+ ✓ 32 gui.spec.js:529:1 › progress uses backend labels while phase IDs remain stable (471ms)
+ ✓ 33 gui.spec.js:548:3 › invalid backend catalogue cannot silently use a handwritten fallback (0) (200ms)
+ ✓ 34 gui.spec.js:548:3 › invalid backend catalogue cannot silently use a handwritten fallback (2) (187ms)
+ ✓ 35 gui.spec.js:555:1 › last-run phase display uses its backend label safely (218ms)
+ ✓ 36 gui.spec.js:565:1 › VM-first account flow uses only implemented choices and supports clean restart (533ms)
+ ✓ 37 gui.spec.js:589:1 › VM-first action shows the next required account detail (650ms)
+ ✓ 38 gui.spec.js:616:1 › E2E drive observes the VM-first button and prepares through the real launchpad form (4.7s)
+ ✓ 39 gui.spec.js:649:1 › signed runtime setup returns to account setup without starting an install (377ms)
+ ✓ 40 gui.spec.js:656:1 › a release without the runtime gives an explicit error without installing Linux (304ms)
+ ✓ 41 gui.spec.js:668:3 › VM preparation drive refuses a directive missing schemaVersion (3.1s)
+ ✓ 42 gui.spec.js:668:3 › VM preparation drive refuses a directive missing runId (3.1s)
+ ✓ 43 gui.spec.js:668:3 › VM preparation drive refuses a directive missing directiveId (3.2s)
+
+ 43 passed (38.3s)
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/01-launchpad.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/01-launchpad.png
new file mode 100644
index 00000000..d2f4e703
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/01-launchpad.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/02-validation.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/02-validation.png
new file mode 100644
index 00000000..37ac471c
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/02-validation.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/03-progress.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/03-progress.png
new file mode 100644
index 00000000..078c805c
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/03-progress.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/04-done.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/04-done.png
new file mode 100644
index 00000000..b8df728a
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/04-done.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/05-control-panel.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/05-control-panel.png
new file mode 100644
index 00000000..316a3106
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/05-control-panel.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/06-migration-dashboard.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/06-migration-dashboard.png
new file mode 100644
index 00000000..aa951e08
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/06-migration-dashboard.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded-bazzite.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded-bazzite.png
new file mode 100644
index 00000000..3be4d311
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded-bazzite.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded.png
new file mode 100644
index 00000000..a9eab582
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/07-branded.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/08-bitlocker.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/08-bitlocker.png
new file mode 100644
index 00000000..571e2b0e
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/08-bitlocker.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/09-vm-mode.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/09-vm-mode.png
new file mode 100644
index 00000000..52873ae3
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/09-vm-mode.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/10-luks-encryption.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/10-luks-encryption.png
new file mode 100644
index 00000000..3f277b7b
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/10-luks-encryption.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/11-defrag-preflight.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/11-defrag-preflight.png
new file mode 100644
index 00000000..b0f54dab
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/11-defrag-preflight.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/12-vm-unavailable.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/12-vm-unavailable.png
new file mode 100644
index 00000000..65fe8360
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/12-vm-unavailable.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/14-bare-minimum-form.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/14-bare-minimum-form.png
new file mode 100644
index 00000000..2ec4cde7
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/14-bare-minimum-form.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/15-vm-first-launchpad.png b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/15-vm-first-launchpad.png
new file mode 100644
index 00000000..3f530470
Binary files /dev/null and b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/15-vm-first-launchpad.png differ
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/provenance.json b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/provenance.json
new file mode 100644
index 00000000..0f04e4b0
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/generated-mock-frontend/provenance.json
@@ -0,0 +1,20 @@
+{
+ "scope": "Actual rendered browser frontend with injected mock engine; no Windows or Linux guest acceptance",
+ "hashes": {
+ "docs/screenshots/01-launchpad.png": "3e9029c8eacd3ec2c693db5a162d30159dcee6c6dafbbb39c2bdf886e8244761",
+ "docs/screenshots/02-validation.png": "0f855b0817cab69a9cb7c3c83c3d42220466d8e30c74b4deb1478b677c9a3e9d",
+ "docs/screenshots/03-progress.png": "515d334b39ee9f39b1c43e37f363d045ef50f436f24c45ac34d94abb49e21d58",
+ "docs/screenshots/04-done.png": "f9bc9b3ffce131e37a20540d181133e386b7794d8331b2deff446beee0c4dfc6",
+ "docs/screenshots/05-control-panel.png": "5aa025518c15f369d15d70dac4af8a6f569f4f711a8aadad2baceacf02adc538",
+ "docs/screenshots/06-migration-dashboard.png": "56552dc838fd3fdc03bd4e0f12e0d1569d57c087df5417ef16d1b486bba5c33b",
+ "docs/screenshots/07-branded-bazzite.png": "54be93c103f9447be5d49b97add2111154a301df442ba100bf76cf1a44bbe7bb",
+ "docs/screenshots/07-branded.png": "340a7868c28ea5516400f17587cfc84930c025e4e481d17e7f062aab7c434d52",
+ "docs/screenshots/08-bitlocker.png": "783fc3c73834a6fd57f7dc60b848cd2c3d8b0d7b7b1765abc916b5cdabc8c36b",
+ "docs/screenshots/09-vm-mode.png": "3140d2e3b42828fb7a5e32764b4da425ef23c133f42254e1df687e53b2c3c70c",
+ "docs/screenshots/10-luks-encryption.png": "b4686e238adb5b6fc8a6882dff30fc033c8a9f8bc749d7404a0415b5994824e2",
+ "docs/screenshots/11-defrag-preflight.png": "21f26a04d74f16970bb68fd75f87c9a8ff4b2716eb429dc00777a7a5dc4be404",
+ "docs/screenshots/12-vm-unavailable.png": "b5e159bbd520240d151c35d473c639681cb7eeb73fed58da8698c95c617dcb5e",
+ "docs/screenshots/14-bare-minimum-form.png": "ca848ed4a9e7909fefe3c83e69cd9cae70314f8ab857965220cffd0d7be39398",
+ "docs/screenshots/15-vm-first-launchpad.png": "8653ce84f5f3d4701326ab401e9c661ec1298d4fad2c3161f3f36000aeebbc94"
+ }
+}
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-go-controls.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-go-controls.log
new file mode 100644
index 00000000..4ac4c216
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-go-controls.log
@@ -0,0 +1,5 @@
+ok wootc 7.336s
+ok wootc/internal/artifactauth 0.030s
+ok wootc/internal/runtimebundle 0.080s
+ok wootc/tools/gendto 0.007s
+ok wootc/tools/signmanifest 0.011s
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-prose.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-prose.log
new file mode 100644
index 00000000..34648d3c
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/integrated-prose.log
@@ -0,0 +1,42 @@
+ 228 docs/agent-lessons.md
+ 227 docs/SPEC.md
+ 125 docs/phase2-attach-postmortem.md
+ 76 docs/borrowed-from-libertix.md
+ 70 docs/ntfs-on-linux.md
+ 65 docs/phase2-debug-plan.md
+ 64 docs/plugin-architecture.md
+ 62 docs/branding-and-distribution.md
+ 49 docs/gui-phase1-architecture.md
+ 46 docs/release-notes-v0.3.0-beta.md
+ 45 docs/manual-testing.md
+ 39 docs/philosophy.md
+ 34 docs/upstream-blessings.md
+ 32 docs/finish-plan.md
+ 31 docs/gui-walkthrough.md
+ 30 docs/session-migration.md
+ 29 docs/milestones.md
+ 27 docs/status.md
+ 24 docs/non-bootc-adoption.md
+ 23 docs/adr/0004-composefs-kernel-module-matching.md
+ 20 docs/alpha-adopter-program.md
+ 19 docs/branding.md
+ 18 docs/adr/0004-restore-vm-first-product.md
+ 17 docs/architecture-boundary.md
+ 15 docs/docs-truth-pass.md
+ 12 docs/adr/0002-hardware-preflight-bootc-matching.md
+ 12 docs/experiments/vm-helper-2026-09-26.md
+ 12 docs/prefilled-profile-testing.md
+ 9 docs/adr/0001-phase1-first-architecture.md
+ 9 docs/branded-walkthroughs.md
+ 9 CONTRIBUTING.md
+ 7 docs/plan.md
+ 6 docs/adr/0003-onedrive-files-on-demand-migration.md
+ 6 docs/vhdx-exploration.md
+ 4 docs/backend-contract.md
+ 4 docs/soak.md
+ 4 docs/systemd-boot.md
+ 1 docs/artifact-authentication.md
+ 1 docs/specs/migration-extensions.md
+ 1 docs/windows-state-trust.md
+
+1512 finding(s) across 40 file(s)
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/old-untyped-directive-failed.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/old-untyped-directive-failed.log
new file mode 100644
index 00000000..2e1aa8a1
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/old-untyped-directive-failed.log
@@ -0,0 +1,39 @@
+[WebServer] (node:3168366) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+[WebServer] (node:3168379) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+
+Running 1 test using 1 worker
+
+(node:3168390) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✘ 1 gui.spec.js:616:1 › E2E drive observes the VM-first button and prepares through the real launchpad form (5.7s)
+
+
+ 1) gui.spec.js:616:1 › E2E drive observes the VM-first button and prepares through the real launchpad form
+
+ Error: expect(locator).toBeVisible() failed
+
+ Locator: getByRole('heading', { name: 'Linux is starting' })
+ Expected: visible
+ Timeout: 5000ms
+ Error: element(s) not found
+
+ Call log:
+ - Expect "toBeVisible" with timeout 5000ms
+ - waiting for getByRole('heading', { name: 'Linux is starting' })
+
+
+ 626 | });
+ 627 |
+ > 628 | await expect(page.getByRole('heading', { name: 'Linux is starting' })).toBeVisible();
+ | ^
+ 629 | const calls = await page.evaluate(() => window.__wootcVMCalls);
+ 630 | expect(calls.map(call => call[0])).toEqual(['prepare']);
+ 631 | expect(calls[0][1]).toEqual({ imageRef: image, username: 'alice', password });
+ at /tmp/wootc-vm-first-current-candidate/tests/gui/gui.spec.js:628:74
+
+ Error Context: .results/gui-E2E-drive-observes-the-392f9-ugh-the-real-launchpad-form/error-context.md
+
+ 1 failed
+ gui.spec.js:616:1 › E2E drive observes the VM-first button and prepares through the real launchpad form
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/provenance.json b/docs/experiments/evidence/2026-09-27-vm-current-integration/provenance.json
new file mode 100644
index 00000000..fcfd15d3
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/provenance.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 1,
+ "base": "d39cc4a48adf5b55bf0a22ea7e498a8ff150810e",
+ "draftHeads": {
+ "418": "e85f9d6fa9cb03fe61623e76485349c878e8d47f",
+ "419": "4fb714534c21aea0a1241e9c4efc05d0b243c242",
+ "422": "5168dd5f9ea6ee96f64f235fdefd7b13913827a9"
+ },
+ "featureIntegrationHead": "f5275bc",
+ "goSuitePassed": true,
+ "realFrontendBuildPassed": true,
+ "focusedFrontendControls": 4,
+ "fullFrontendControls": 43,
+ "actualGuardRemovalFailed": true,
+ "guestExecutionObserved": false,
+ "desktopAcceptance": false,
+ "scope": "Integrated source and frontend with mock engine only. No native Windows runtime, Linux desktop/editor/persistence or promotion acceptance.",
+ "conflictResolution": "Preserved main ordered installer step catalogue and root preservation checks; inserted managed disk lease; combined frontend imports and release notices; kept native typed drive/reboot correlation and extended it to prepare-vm. No older disk/NTFS/firstboot/runner/auth branch state imported.",
+ "newChanges": {
+ "tests/gui/gui.spec.js": "75ec57b2802bcefe8cc7a7f4c97ba1730eac442dd3e0fd3c341d4aad16b447f9",
+ "tests/gui/mock-backend.js": "64290999cfc96e5adf2f7f4eb53cf020d9b10ce15b493af2118e57a6d45735af",
+ "app/frontend/src/lib/e2e.js": "02d55c01f2b4e60c1c246b972627186e14d8cab3479ccdc1ec9da0ab3eb6f059"
+ },
+ "evidenceHashes": {
+ "old-untyped-directive-failed.log": "632226e39aa0c35742a08f54576356db17622870e6e49cc91bacdbc6f37f8d30",
+ "typed-drive-controls.log": "cf24b3887dfd86f5d5b0b08902370321269ffb205aebc998c531f80088e08c0b",
+ "removed-guard-mutant.log": "e56510372206b0c7cae12d2288a4aebe3dca9beedbd469d7483764990f50c5d9",
+ "full-frontend-controls.log": "03cfc7ab0999c83b4fc6316a1af8982ca4b29e30eb67ab28a08c35438de1ecaa",
+ "removed-guard-mutant.json": "c5af4b0b52b6ed8903fd43d1d65d193021bc35f63376642e4427877ae2518ce1",
+ "integrated-go-controls.log": "261d5a926edbadecfc9471f5793e390489bc1bf3ed1780b14f4e647fbaa04de3",
+ "integrated-prose.log": "9233fe5e6202ff272151a3f6374c2e9477434220cd2c52a701f060c16ad13f80"
+ },
+ "sharedEnglishFindings": 1512,
+ "englishBudget": 1512,
+ "generatedMockFrames": 15,
+ "codeScopeMatches": {
+ "vmFiles": "each byte-identical to reviewed418 or422",
+ "runtimeInstaller": "byte-identical419",
+ "runtimeDoc": "one plain-English noun-cluster correction; technical claims unchanged"
+ }
+}
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.json b/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.json
new file mode 100644
index 00000000..dbc3d966
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.json
@@ -0,0 +1,7 @@
+{
+ "sourceBeforeSha256": "02d55c01f2b4e60c1c246b972627186e14d8cab3479ccdc1ec9da0ab3eb6f059",
+ "mutant": "removed actual directive schema check",
+ "test": "VM preparation drive refuses a directive missing schemaVersion",
+ "mutantExit": 1,
+ "sourceRestoredSha256": "02d55c01f2b4e60c1c246b972627186e14d8cab3479ccdc1ec9da0ab3eb6f059"
+}
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.log
new file mode 100644
index 00000000..11bbf643
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/removed-guard-mutant.log
@@ -0,0 +1,44 @@
+[WebServer] (node:3170408) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+[WebServer] (node:3170423) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+
+Running 1 test using 1 worker
+
+(node:3170434) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✘ 1 gui.spec.js:668:3 › VM preparation drive refuses a directive missing schemaVersion (3.3s)
+
+
+ 1) gui.spec.js:668:3 › VM preparation drive refuses a directive missing schemaVersion ────────────
+
+ Error: expect(received).toEqual(expected) // deep equality
+
+ - Expected - 1
+ + Received + 10
+
+ - Array []
+ + Array [
+ + Array [
+ + "prepare",
+ + Object {
+ + "imageRef": "ghcr.io/tuna-os/yellowfin:kde",
+ + "password": "disposable-control-password",
+ + "username": "alice",
+ + },
+ + ],
+ + ]
+
+ 673 | freshVm: { available: true, probeStatus: 'passed' }, driveDirective: JSON.stringify(directive) });
+ 674 | await expect.poll(() => page.evaluate(() => window.__wootcE2EDirectiveReads)).toBeGreaterThanOrEqual(2);
+ > 675 | expect(await page.evaluate(() => window.__wootcVMCalls)).toEqual([]);
+ | ^
+ 676 | expect(await page.evaluate(() => window.__wootcE2EReports)).toEqual([]);
+ 677 | await expect(page.locator('#vm-prepare-btn')).toBeVisible();
+ 678 | });
+ at /tmp/wootc-vm-first-current-candidate/tests/gui/gui.spec.js:675:62
+
+ Error Context: .results/gui-VM-preparation-drive-r-69caa-ctive-missing-schemaVersion/error-context.md
+
+ 1 failed
+ gui.spec.js:668:3 › VM preparation drive refuses a directive missing schemaVersion ─────────────
diff --git a/docs/experiments/evidence/2026-09-27-vm-current-integration/typed-drive-controls.log b/docs/experiments/evidence/2026-09-27-vm-current-integration/typed-drive-controls.log
new file mode 100644
index 00000000..76b4efaa
--- /dev/null
+++ b/docs/experiments/evidence/2026-09-27-vm-current-integration/typed-drive-controls.log
@@ -0,0 +1,15 @@
+[WebServer] (node:3169632) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+[WebServer] (node:3169644) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+
+Running 4 tests using 1 worker
+
+(node:3169675) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✓ 1 gui.spec.js:616:1 › E2E drive observes the VM-first button and prepares through the real launchpad form (4.8s)
+ ✓ 2 gui.spec.js:668:3 › VM preparation drive refuses a directive missing schemaVersion (3.2s)
+ ✓ 3 gui.spec.js:668:3 › VM preparation drive refuses a directive missing runId (3.2s)
+ ✓ 4 gui.spec.js:668:3 › VM preparation drive refuses a directive missing directiveId (3.2s)
+
+ 4 passed (18.1s)
diff --git a/docs/experiments/vm-scoped-qmp-2026-09-27.md b/docs/experiments/vm-scoped-qmp-2026-09-27.md
new file mode 100644
index 00000000..dbded44b
--- /dev/null
+++ b/docs/experiments/vm-scoped-qmp-2026-09-27.md
@@ -0,0 +1,44 @@
+# Scoped QMP source controls — 2026-09-27
+
+This source extends the managed engine from candidate `8e1c93e`.
+It includes main `347f696`. It does not change the runtime or image.
+The engine remains the sole owner of its inherited QMP pipes.
+There is no new listener or public command endpoint.
+
+The live session clears `DesktopReady` at start.
+A record on disk cannot replace current guest semantics.
+
+| Boundary | Source behavior |
+|---|---|
+| Replies | Typed return/error; duplicate fields, absent return, null, unknown IDs and replay refuse |
+| Calls | Ten-second maximum; context-aware write gate; short writes and timeout refuse |
+| Session | Fresh nonce per process launch; persisted install/run/GPT identity remains separate |
+| Display route | Internal engine method; exact session/directive identity; no raw QMP or guest command |
+| Query | Successful typed running state and strict mouse inventory |
+| Keys | Bounded enum; balanced press/release batch; exact successful acknowledgment |
+| Capture | Engine selects private path; verifies PPM header, dimensions, pixels and SHA-256 |
+| Retention | Actual inventory across launches; eight captures and 32 MiB maximum; exhaustion refuses before QMP |
+| Cleanup | Only failed fresh capture directory; prior proof remains until explicit consumer release |
+| Lifecycle | Display actions serialize with stop/force; explicit force remains possible during guest shutdown wait |
+| Desktop | Always unqualified; process/QMP/framebuffer cannot establish editor or guest identity |
+
+The API defines replies with types for status, mice and input events.
+It defines PPM as the default capture format, with an empty acknowledgment on success.
+[QEMU 8.2 QMP reference](https://qemu.readthedocs.io/en/v8.2.10/interop/qemu-qmp-ref.html#screendump-command).
+
+The approved native control uses QEMU 8.2.2 with TCG and `-S`.
+It uses 128 MiB, no attached disk, no network and no guest installation.
+The actual status was `prelaunch`, with `running` false.
+The engine verified an actual 640×480 PPM and rejected a failed capture command.
+This proves the protocol component only. It does not prove execution of a guest CPU or desktop acceptance.
+
+The child processes use inherited pipes to exercise the actual engine.
+Their simulated status/editor context cannot qualify a real guest.
+The old parser accepted a matching capability ID without a return field.
+The control for the old source fails. The current control passes.
+Controls also fail if I remove guards for replies, sessions, active state or pixels.
+
+The internal route still needs an RPC adapter for tests. Review that adapter separately.
+No channel exists for current guest semantics.
+Real Windows/WHPX execution, ordinary-user GNOME identity, and editor save/close/reopen remain open.
+The full persistence gate must use the same installed disk and firmware across a clean stop and restart.
diff --git a/docs/experiments/windows-vm-probe-appcompat-2026-09-26.md b/docs/experiments/windows-vm-probe-appcompat-2026-09-26.md
new file mode 100644
index 00000000..cc6e1ad8
--- /dev/null
+++ b/docs/experiments/windows-vm-probe-appcompat-2026-09-26.md
@@ -0,0 +1,38 @@
+# Windows VM capability probe mutates the signed runtime — 2026-09-26
+
+The Corral test on Windows showed no VM-first action, though QEMU's runtime
+archive passed its check. The capability response was:
+
+```json
+{
+ "available": false,
+ "reason": "runtime file is not signed: %SystemDrive%/ProgramData/Microsoft/Windows/Caches/cversions.2.db"
+}
+```
+
+The first probe launched QEMU with its current directory set to
+`C:\wootc\qemu`. Windows created `cversions.2.db` and two other app cache files
+under a folder named `%SystemDrive%\ProgramData`. Those files were absent from the signed runtime manifest. On a
+subsequent app start, the installer-state trust scan also rejected the
+user-writable cache file. The manifest and trust checks worked; the probe
+changed the signed runtime.
+
+The probe now runs from its private `.probe-*` folder under the disposable
+preview directory. QEMU and its DLLs stay in the signed `qemu` bundle. The
+probe removes the folder when it exits. A regression test asserts that QEMU
+starts outside the runtime folder.
+
+This trial shows that the probe can change the signed runtime. Rebuild the
+Windows app and repeat the visible first-launch test before you call VM-first
+ready on Windows.
+
+## Retest outcome
+
+The first rebuilt app lacked the public key that signed the test runtime. It
+refused the runtime before the probe. I recovered the old app's public key and
+checked the runtime signature on the build host.
+
+The KubeVirt log then reported a guest-agent shutdown event. Its VMI stayed
+ready, but Windows did not reconnect QGA after a restart. I stopped the VMI
+after 20 minutes and kept its PVC. This run did not confirm the probe fix on
+Windows.
diff --git a/docs/experiments/windows-vm-runtime-2026-09-26.md b/docs/experiments/windows-vm-runtime-2026-09-26.md
new file mode 100644
index 00000000..3b0e68cc
--- /dev/null
+++ b/docs/experiments/windows-vm-runtime-2026-09-26.md
@@ -0,0 +1,98 @@
+# Windows VM runtime experiment, 2026-09-26
+
+The VM-first path needs a runtime that the installer can verify and fetch.
+A user should not need to find and install QEMU by hand.
+This experiment tests a smaller x86 bundle. It does not enable a new release asset.
+
+## Build
+
+`packaging/build-vm-runtime.py` checks the pinned installer SHA-512 before extraction.
+It does not run the upstream installer. It retains the x86 emulator, all root DLLs,
+x86 firmware, display data, documentation and license files.
+It adds the helper kernel, initramfs and protocol file from a local build.
+
+Each file enters `SHA256SUMS`. The build signs that manifest with the release seed.
+The seed stays outside the bundle. The app must embed the matching public key.
+The outer release manifest must also cover the ZIP before the app extracts it.
+See [artifact authentication](../artifact-authentication.md) for the trust model.
+
+```bash
+python3 packaging/build-vm-runtime.py \
+ --helper-dir /path/to/helper-artifacts \
+ --signing-seed /private/build/seed \
+ --output /path/to/wootc-vm-runtime.zip
+```
+
+Go, Python 3.11 or later, and 7-Zip must be available.
+Use `--installer` for a cached input. The same hash check still applies.
+Use `--seven-zip` when the extraction tool is outside PATH.
+
+## Measured results
+
+| Artifact | Bytes |
+|---|---:|
+| Upstream installer | 206,615,928 |
+| Full upstream installation, all architectures | 1,255,998,954 |
+| Selected runtime, with helper, unpacked | 321,059,722 |
+| Selected runtime ZIP, with helper | 183,295,958 |
+
+The signed manifest covers 3,282 files. The checks passed for the signature and each file hash
+after ZIP creation. The [bundle record](evidence/2026-09-26-vm-runtime/bundle.json)
+pins the ZIP and public key. The helper came from commit `4f9985d`.
+It predates account setup; a release must use the final contract for the helper.
+
+The probe copied the ZIP to a temporary directory on the Corral Windows 11 fixture.
+It checked SHA-256 before extraction. PATH excluded the installed QEMU copy.
+The probe used private temporary and home directories and cleared display overrides.
+
+- Headless TCG executed the boot-sector marker in 0.545 seconds.
+- GTK in Windows session 1 executed the same marker in 2.540 seconds.
+- Loaded modules came from the bundle or Windows system directories.
+- GTK accepted `window-close=off,show-menubar=off`.
+
+The [headless log](evidence/2026-09-26-vm-runtime/windows-tcg.log) and
+[interactive log](evidence/2026-09-26-vm-runtime/windows-gtk-interactive.log)
+record those checks. The scripts in the same directory show their scope.
+These short TCG probes do not measure a Linux desktop or its performance.
+
+An earlier GTK test in session 0 failed to execute the marker in 30 seconds.
+GDK reported that no monitor existed. Its
+[failure log](evidence/2026-09-26-vm-runtime/windows-gtk-session0.log) remains in the record.
+The repeat used a task with an interactive principal and no time trigger.
+It did not change the runtime files or display flags.
+Do not launch the desktop from a service session.
+
+## Verified extraction
+
+`app/internal/runtimebundle.Install` checks the ZIP hash against the outer manifest,
+then verifies the signature and all files in the inner manifest.
+The caller must hold the VM lease and supply a protected parent directory.
+The limits are 256 MiB for the ZIP, 512 MiB after extraction, 128 MiB per file,
+and 8,192 entries. These limits fit the measured bundle, including its helper.
+
+The extractor rejects links, extra or missing files, duplicate paths, differences
+in path case, Windows device names, alternate streams and paths outside the bundle.
+It writes to a private temporary directory and publishes through one rename.
+It refuses to change an existing runtime. Cancellation or a failed check removes temporary files.
+A process crash can leave private files in a temporary directory; it cannot publish a partial runtime.
+
+The real archive passed this installer on Linux and native Windows.
+Each run authenticated and installed 3,284 files, including the two manifest files.
+The [native test log](evidence/2026-09-26-vm-runtime/windows-install.log) also records
+the negative tests for corrupt payloads, unsafe paths, missing files and cancellation.
+The edited-payload test failed when we removed the hash comparison.
+
+## Remaining release gates
+
+Prove helper preparation and a usable Linux desktop with this exact bundle.
+Test restart, persisted work, shutdown, keyboard input and missing dependencies.
+The app must offer a clear shutdown control when the QEMU close button is disabled.
+
+Connect the extractor to verified download and the setup controls in the app.
+Do not trust an unsigned local manifest.
+
+Record the exact corresponding source and build inputs for QEMU and its DLLs
+before distribution. The upstream binary reports commit `e470268ff4`;
+this experiment did not resolve the source commit or full dependency record.
+The license files do not complete that provenance work.
+No release workflow publishes this experimental ZIP.
diff --git a/docs/managed-vm.md b/docs/managed-vm.md
new file mode 100644
index 00000000..7ba7f24e
--- /dev/null
+++ b/docs/managed-vm.md
@@ -0,0 +1,142 @@
+# Managed Windows VM lifecycle
+
+The VM uses one Linux system in `:\wootc\disks\root.disk`.
+A restart reuses that image and `vm\firmware-vars.fd`.
+It does not redeploy Linux. Native promotion needs more proof (ADR 0004).
+
+Standard releases still need to publish the signed runtime package.
+The GUI offers explicit setup through `InstallVMRuntime`.
+An executable alone is insufficient. The engine checks the complete runtime
+against a signature before execution. An absent bundle makes the capability
+unavailable. It does not cause a silent fallback to native install.
+
+Setup fetches the signed release manifest before it downloads
+`wootc-vm-runtime.zip`. An absent entry gives a clear unavailable error.
+The download has a 256 MiB limit. Extraction verifies the archive hash and
+each signed file, then publishes the complete directory in one rename.
+
+Setup owns the image lock. It needs a protected parent and 2 GiB free.
+Cancellation removes temporary files. A retry removes only reserved temporary
+paths; it preserves an existing runtime and every Linux disk.
+
+## Runtime contract
+
+Stage QEMU for Windows 11.1.0 under `wootc\qemu`.
+The tested installer supplies these files:
+
+| Path | Purpose |
+|---|---|
+| `qemu-system-x86_64.exe` | VM process, with all its DLLs and support files |
+| `share/edk2-x86_64-code.fd` | Firmware code; 3,653,632 bytes |
+| `share/edk2-i386-vars.fd` | Firmware template; 540,672 bytes; copy once per installation |
+| `builder-vmlinuz`, `builder-initramfs.img` | Initial preparation helper |
+| `SHA256SUMS`, `SHA256SUMS.sig` | Domain-bound manifest, signed by the key embedded in this installer |
+
+The manifest uses canonical relative paths, with no ambiguity about case.
+Every runtime file must match the manifest. It rejects a DLL absent from the list. It also rejects links and changed files. Logs and writable firmware data stay outside this tree.
+The installer checks permissions and reparse points before it loads the runtime.
+
+QEMU uses an absolute path and a protected directory.
+Its environment has an explicit allowlist. It does not inherit user PATH,
+GTK/GIO/QEMU module paths, or user configuration locations.
+
+WHPX must be enabled. The launcher selects `-cpu max`.
+Before it enables preparation, the engine boots a small probe with WHPX.
+The probe must emit a marker from actual guest code through a fresh serial file.
+It uses a read-only disk with no operating system or user files.
+
+The check has a 30-second deadline. A timeout is inconclusive on a slow or busy PC;
+it does not prove incompatibility.
+The cache expires after one minute and keys on the signed manifest.
+The measured mode is `whpx,kernel-irqchip=off`; there is no TCG fallback.
+
+Each image still needs proof of CPU compatibility, especially with x86-64-v3.
+A process does not prove acceleration, guest boot, or a usable desktop.
+
+## Preparation and ownership
+
+| Boundary | Required evidence or behavior |
+|---|---|
+| Target and scratch | Separate 40 GiB raw files; serials `wootc-root` and `wootc-scratch` |
+| Helper memory | 3 GiB guest allocation; at least 6 GiB host RAM; lower-memory profiles need separate proof |
+| Host capacity | At least 88 GiB free before file creation: target, scratch, and 8 GiB for Windows |
+| Image | Immutable OCI digest; fresh installation and run IDs |
+| Existing file | Never truncate or replace; preserve failed preparation for diagnosis |
+| Helper completion | Exit zero, one structured receipt, then `STATUS=SUCCESS` |
+| Receipt identity | Exact image/run/install IDs and the GPT GUID read from the target |
+| Receipt validation | `filesystemVerified=true`, `efiVerified=true`, `accountOutcome=created` and exact username |
+| Result transport | Private file-backed virtio channel owned by this QEMU launch; no public socket |
+
+The 88 GiB gate is a conservative trial limit, not a measured physical minimum.
+The files are sparse. The helper stops if free space falls below the Windows reserve.
+Product defaults still need a measurement of peak physical space on Windows.
+A lighter image with a 2 GiB helper also needs proof for older PCs.
+These are P0 requirements for the consumer path.
+
+The host sends account input through private `fw_cfg` data.
+The file contains a password hash, never a plaintext password.
+It stays under the protected VM directory, outside logs and durable state.
+The host removes it after the helper exits. After a crash, cleanup first takes
+the image lock. It removes only account input, not the Linux disk.
+
+The GUI reuses the Linux username and password fields. It derives the username
+from Windows when possible. `PrepareVM` accepts these choices. Without an account,
+`TryInVMFresh` refuses preparation.
+
+This evidence proves the installed disk and the helper's account result.
+A usable login and desktop still need independent guest evidence.
+
+A mutex in Windows protects the image across engine processes.
+An exclusive file handle also detects an unmanaged process that still has the
+image open. Install, native boot, and uninstall use the same lock.
+Native redeployment refuses a managed image until promotion can preserve it.
+Uninstall locks each drive that it will clean. It keeps the disk and VM record
+unless the user chooses to delete them.
+
+## Stop, restart and recovery
+
+The viewer disables an abrupt quit through GTK.
+Use the visible **Shut down Linux** control in the main app, or shut down inside Linux.
+The main app can also close itself: it first requests a guest shutdown.
+These controls prevent a silent power cut when a user closes the window.
+
+QMP uses private inherited pipes. A job object in Windows owns the QEMU process.
+The host creates QEMU suspended, assigns the job, then resumes it.
+A crash before assignment can leave an inert process, but no unowned writer.
+
+If the engine exits, Windows stops that process. The engine writes state before
+launch. An interruption leaves a record that needs recovery.
+It never trusts a reused process ID.
+
+| Action or state | Contract |
+|---|---|
+| `StopVM` | Request ACPI shutdown; wait up to 90 seconds |
+| Clean stop | Actual process exit and QMP `SHUTDOWN`, with `guest=true` and `reason=guest-shutdown` |
+| Shutdown timeout | Guest keeps its writer lock; no clean-stop claim |
+| `ForceStopVM` | Explicit force; mark the image as `needs_recovery` |
+| Engine close | Request shutdown; force after the deadline; record recovery if unclean |
+| Restart | Only from `ready` or `stopped`, with the same GPT identity |
+| Stale active state | Recovery required; no automatic restart |
+| `desktopReady` | Always false until independent guest evidence exists |
+
+The bounded wait allows the engine to exit. A forced stop never becomes a
+clean stop. Follow-up work includes filesystem recovery, account/login proof,
+a desktop probe, and promotion of the same system to native boot.
+
+The shutdown evidence follows the [QEMU QMP reference](https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#event-SHUTDOWN).
+A guest request to power off does not prove that each application saved its documents.
+
+## Probe source
+
+The embedded boot sector is 512 bytes. Its source is in
+`app/vmprobe/serial-boot.S`. Reproduce it with GNU binutils:
+
+```sh
+as --32 app/vmprobe/serial-boot.S -o /tmp/probe.o
+ld -m elf_i386 -Ttext 0x7c00 --oformat binary -e _start /tmp/probe.o -o /tmp/probe.img
+sha256sum /tmp/probe.img app/vmprobe/serial-boot.img
+```
+
+Both hashes must be
+`f591bb62b9fce510dc7a4a096940ffbfc596d925739416559017cfb2a92e7195`.
+This probe proves execution of guest instructions, not Linux or desktop compatibility.
diff --git a/docs/screenshots/15-vm-first-launchpad.png b/docs/screenshots/15-vm-first-launchpad.png
new file mode 100644
index 00000000..827af6fd
Binary files /dev/null and b/docs/screenshots/15-vm-first-launchpad.png differ
diff --git a/docs/winui-shell.md b/docs/winui-shell.md
index 464d933e..d1360a9f 100644
--- a/docs/winui-shell.md
+++ b/docs/winui-shell.md
@@ -234,6 +234,15 @@ Retain the legacy build until a native release passes its gates, before phase E
| `GetRecoveryVerdict` | — | `RecoveryVerdict` |
| `E2EDriveDirective` | — | `string` |
| `E2EDriveReport` | `string` | `null` |
+| `GetVMCapability` | — | `VMCapability` | Signed runtime and persistent disk availability. |
+| `GetFreshVMCapability` | — | `VMCapability` | Signed runtime and helper availability. |
+| `GetVMState` | — | `VMState` | Durable disk lifecycle; desktop readiness requires independent evidence. |
+| `TryInVMFresh` | image string | — | Legacy call refuses preparation without account details. |
+| `InstallVMRuntime` | — | — | Acquire the signed runtime for this release; no native fallback or existing-runtime replacement. |
+| `PrepareVM` | `VMInstallConfig` | — | Prepare a persistent system with its Linux account, then launch its VM. |
+| `BootInVM` | — | — | Restart the same verified, stopped VM disk. |
+| `StopVM` | — | — | Request guest shutdown; preserve ownership until actual exit. |
+| `ForceStopVM` | — | — | Explicit forced stop; disk requires recovery before reuse. |
| `Shutdown` | — | `null` (engine exits 0) |
Notifications use `install:progress` and `vm:progress`.
diff --git a/packaging/build-vm-runtime.py b/packaging/build-vm-runtime.py
new file mode 100644
index 00000000..39dc6c43
--- /dev/null
+++ b/packaging/build-vm-runtime.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Package the pinned x86 Windows QEMU runtime without executing its installer.
+
+The private manifest seed stays outside the archive. Every selected file,
+including DLLs and helper images, is covered by the inner signed manifest.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import urllib.request
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / "packaging/qemu-windows-source.json"
+REQUIRED = ("qemu-system-x86_64.exe", "COPYING", "COPYING.LIB", "README.rst",
+ "VERSION", "share/edk2-x86_64-code.fd", "share/edk2-i386-vars.fd",
+ "share/edk2-licenses.txt", "share/bios-256k.bin", "share/vgabios-stdvga.bin")
+SHARE_DIRS = {"doc", "icons", "keymaps", "locale", "man"}
+SHARE_FILES = {"edk2-x86_64-code.fd", "edk2-x86_64-secure-code.fd", "edk2-i386-vars.fd",
+ "edk2-licenses.txt", "bios.bin", "bios-256k.bin", "bios-microvm.bin",
+ "kvmvapic.bin", "linuxboot_dma.bin", "multiboot_dma.bin", "pvh.bin",
+ "qboot.rom", "trace-events-all"}
+
+
+def digest(path, algorithm="sha256"):
+ with path.open("rb") as stream:
+ return hashlib.file_digest(stream, algorithm).hexdigest()
+
+
+def selected_files(source):
+ """Keep x86 system emulator, all DLLs, display data, docs and x86 firmware.
+
+ Keeping all DLLs also retains dependencies loaded dynamically by display
+ drivers. PE import-table traversal alone does not establish their absence.
+ """
+ for name in REQUIRED:
+ if not (source / name).is_file():
+ raise ValueError(f"QEMU runtime input missing: {name}")
+ files = []
+ for path in sorted(source.rglob("*")):
+ if path.is_symlink():
+ raise ValueError(f"runtime source contains a symlink: {path}")
+ if not path.is_file():
+ continue
+ relative = path.relative_to(source)
+ name = relative.as_posix()
+ parts = relative.parts
+ keep = name in REQUIRED
+ if len(parts) == 1 and path.suffix.lower() == ".dll":
+ keep = True
+ if parts[0] == "share" and len(parts) > 1:
+ keep |= parts[1] in SHARE_DIRS
+ keep |= len(parts) == 2 and (parts[1] in SHARE_FILES or
+ parts[1].startswith(("vgabios", "efi-", "pxe-")))
+ if len(parts) == 3 and parts[1] == "firmware":
+ keep |= "x86_64" in parts[2] or "i386" in parts[2]
+ if parts[0] == "lib":
+ keep = True
+ if keep:
+ if any(char.isspace() for char in name) or "\\" in name or ":" in name:
+ raise ValueError(f"unsupported manifest filename: {name}")
+ files.append(relative)
+ return files
+
+
+def create_runtime(source, destination, helper, provenance):
+ destination.mkdir(parents=True, exist_ok=False)
+ for relative in selected_files(source):
+ output = destination / relative
+ output.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source / relative, output)
+ for name in ("builder-vmlinuz", "builder-initramfs.img", "builder-protocol.json"):
+ if not (helper / name).is_file() or (helper / name).stat().st_size == 0:
+ raise ValueError(f"missing helper artifact: {name}")
+ shutil.copyfile(helper / name, destination / name)
+ protocol = json.loads((destination / "builder-protocol.json").read_text())
+ if protocol.get("protocol") != "wootc-helper" or protocol.get("protocolVersion") != 1:
+ raise ValueError("unsupported VM helper protocol")
+ (destination / "runtime-source.json").write_text(json.dumps(provenance, indent=2) + "\n")
+ lines = [f"{digest(path)} {path.relative_to(destination).as_posix()}\n"
+ for path in sorted(destination.rglob("*")) if path.is_file()]
+ (destination / "SHA256SUMS").write_text("".join(lines))
+
+
+def archive_runtime(source, output):
+ output.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for path in sorted(source.rglob("*")):
+ if not path.is_file():
+ continue
+ name = "qemu/" + path.relative_to(source).as_posix()
+ info = zipfile.ZipInfo(name, date_time=(2000, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = 0o100644 << 16
+ with path.open("rb") as incoming, archive.open(info, "w", force_zip64=True) as outgoing:
+ shutil.copyfileobj(incoming, outgoing)
+
+
+def build(installer, helper, seed, output, seven_zip):
+ provenance = json.loads(SOURCE.read_text())
+ with tempfile.TemporaryDirectory(prefix="wootc-vm-runtime-") as temp:
+ work = Path(temp)
+ if installer is None:
+ installer = work / "qemu-setup.exe"
+ with urllib.request.urlopen(provenance["url"], timeout=120) as response, installer.open("wb") as target:
+ shutil.copyfileobj(response, target)
+ if digest(installer, "sha512") != provenance["sha512"]:
+ raise ValueError("QEMU installer does not match the pinned SHA-512")
+ extracted = work / "extracted"
+ subprocess.run([seven_zip, "x", "-y", f"-o{extracted}", str(installer)], check=True,
+ stdout=subprocess.DEVNULL)
+ runtime = work / "qemu"
+ create_runtime(extracted, runtime, helper, provenance)
+ subprocess.run(["go", "run", "./tools/signmanifest", "sign", str(seed),
+ str(runtime / "SHA256SUMS"), str(runtime / "SHA256SUMS.sig")],
+ cwd=ROOT / "app", check=True)
+ archive_runtime(runtime, output)
+ print(json.dumps({"archive": str(output), "bytes": output.stat().st_size,
+ "sha256": digest(output), "runtime_bytes": sum(
+ p.stat().st_size for p in runtime.rglob("*") if p.is_file())}))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--installer", type=Path, help="reuse a cached installer; SHA-512 is still mandatory")
+ parser.add_argument("--helper-dir", required=True, type=Path)
+ parser.add_argument("--signing-seed", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--seven-zip", default=shutil.which("7zz") or shutil.which("7z"))
+ args = parser.parse_args()
+ if not args.seven_zip:
+ parser.error("7-Zip is required to extract the verified NSIS installer")
+ try:
+ build(args.installer.resolve() if args.installer else None, args.helper_dir.resolve(),
+ args.signing_seed.resolve(), args.output.resolve(), args.seven_zip)
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
+ parser.exit(1, f"VM runtime build failed: {error}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packaging/qemu-windows-source.json b/packaging/qemu-windows-source.json
new file mode 100644
index 00000000..de1d6f02
--- /dev/null
+++ b/packaging/qemu-windows-source.json
@@ -0,0 +1,7 @@
+{
+ "version": "11.1.0",
+ "url": "https://qemu.weilnetz.de/w64/2026/qemu-w64-setup-20260811.exe",
+ "sha512": "5bcf9eed634e8575a37b74f445af41a2fe4106da512d0c30c368301d4c105037fdfab40a5287367a28a957624cddebbc8c07e16c88ab6634f554cdf3d16bf543",
+ "source": "https://qemu.weilnetz.de/w64/2026/",
+ "upstream_downloads": "https://www.qemu.org/download/"
+}
diff --git a/shell/Wootc.Shell/Engine/Dto.cs b/shell/Wootc.Shell/Engine/Dto.cs
index 1a5b5e20..e07acff7 100644
--- a/shell/Wootc.Shell/Engine/Dto.cs
+++ b/shell/Wootc.Shell/Engine/Dto.cs
@@ -806,6 +806,86 @@ public class VMCapability
[JsonPropertyName("bundled")]
public bool Bundled { get; set; }
+
+ [JsonPropertyName("probeStatus")]
+ public string ProbeStatus { get; set; } = string.Empty;
+
+ [JsonPropertyName("runtimeNeeded")]
+ public bool RuntimeNeeded { get; set; }
+}
+
+///
+/// VMState records the persistent installed system, not just a process. Ready
+/// means provisioning verified the disk; DesktopReady needs separate guest proof.
+///
+public class VMState
+{
+ [JsonPropertyName("schemaVersion")]
+ public int SchemaVersion { get; set; }
+
+ [JsonPropertyName("installId")]
+ public string InstallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("runId")]
+ public string RunId { get; set; } = string.Empty;
+
+ [JsonPropertyName("image")]
+ public string Image { get; set; } = string.Empty;
+
+ [JsonPropertyName("username")]
+ public string? Username { get; set; }
+
+ [JsonPropertyName("accountOutcome")]
+ public string? AccountOutcome { get; set; }
+
+ [JsonPropertyName("diskId")]
+ public string DiskId { get; set; } = string.Empty;
+
+ [JsonPropertyName("diskPath")]
+ public string DiskPath { get; set; } = string.Empty;
+
+ [JsonPropertyName("storageProfile")]
+ public string? StorageProfile { get; set; }
+
+ [JsonPropertyName("targetCapacityBytes")]
+ public ulong TargetCapacityBytes { get; set; }
+
+ [JsonPropertyName("scratchCapacityBytes")]
+ public ulong ScratchCapacityBytes { get; set; }
+
+ [JsonPropertyName("runtimeManifestSHA256")]
+ public string? RuntimeManifestSHA256 { get; set; }
+
+ [JsonPropertyName("phase")]
+ public string Phase { get; set; } = string.Empty;
+
+ [JsonPropertyName("desktopReady")]
+ public bool DesktopReady { get; set; }
+
+ [JsonPropertyName("pid")]
+ public int Pid { get; set; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+
+ [JsonPropertyName("updatedAt")]
+ public string UpdatedAt { get; set; } = string.Empty;
+}
+
+///
+/// VMInstallConfig contains only the choices implemented by the VM preparation
+/// path. Do not reuse native-install encryption/migration flags as implied promises.
+///
+public class VMInstallConfig
+{
+ [JsonPropertyName("imageRef")]
+ public string ImageRef { get; set; } = string.Empty;
+
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("password")]
+ public string Password { get; set; } = string.Empty;
}
///
diff --git a/tests/gui/gui.spec.js b/tests/gui/gui.spec.js
index 5cc4ccc6..c8368a65 100644
--- a/tests/gui/gui.spec.js
+++ b/tests/gui/gui.spec.js
@@ -613,6 +613,122 @@ test('last-run phase display uses its backend label safely', async ({ page }) =>
expect(await page.evaluate(() => window.phaseInjected)).toBeUndefined();
});
+test('VM-first account flow uses only implemented choices and supports clean restart', async ({ page }) => {
+ await boot(page, { mode: 'installer', images: IMAGES, sysinfo: SYSINFO, freshVm: { available: true, probeStatus: 'passed' }, vm: { available: true } });
+ await page.locator('.field:has-text("Linux Username") input').fill('alice');
+ const password = page.locator('input[type=password]');
+ await password.nth(0).fill('temporary-test-password');
+ await password.nth(1).fill('different');
+ await expect(page.locator('#vm-prepare-btn')).toBeDisabled();
+ await password.nth(1).fill('temporary-test-password');
+ await expect(page.locator('#vm-prepare-btn')).toHaveClass(/btn-primary/);
+ await expect(page.locator('#plan-note')).toContainText('apply only to installation with a reboot');
+ await page.locator('#vm-prepare-btn').click();
+ await expect(page.getByRole('heading', { name: 'Linux is starting' })).toBeVisible();
+ const calls = await page.evaluate(() => window.__wootcVMCalls);
+ expect(calls[0][0]).toBe('prepare');
+ expect(calls[0][1].username).toBe('alice');
+ expect(calls[0][1].password).toBe('temporary-test-password');
+ expect(Object.keys(calls[0][1]).sort()).toEqual(['imageRef', 'password', 'username']);
+ await expect(page.locator('body')).not.toContainText('temporary-test-password');
+ await page.getByRole('button', { name: 'Shut down Linux', exact: true }).click();
+ await expect(page.getByRole('button', { name: 'Start Linux again' })).toBeVisible();
+ await page.getByRole('button', { name: 'Start Linux again' }).click();
+ expect(await page.evaluate(() => window.__wootcVMCalls.map(call => call[0]))).toEqual(['prepare', 'stop', 'boot']);
+});
+
+test('VM-first action shows the next required account detail', async ({ page }) => {
+ await boot(page, { mode: 'installer', images: IMAGES, sysinfo: SYSINFO,
+ freshVm: { available: true, probeStatus: 'passed' } });
+
+ const vmButton = page.locator('#vm-prepare-btn');
+ const vmHint = page.locator('#vm-prepare-hint');
+ await expect(vmButton).toBeInViewport();
+ await expect(vmButton).toBeDisabled();
+ await expect(vmButton).toHaveText(/Start .* in a window/);
+ await expect(vmButton).toHaveAttribute('aria-describedby', 'vm-prepare-hint');
+ await expect(vmHint).toHaveAttribute('role', 'status');
+ await expect(vmHint).toHaveAttribute('aria-live', 'polite');
+ await expect(vmHint).toHaveText('Enter a valid Linux username below');
+
+ await page.locator('.field:has-text("Linux Username") input').fill('alice');
+ await expect(vmHint).toHaveText('Set your Linux password below');
+ await shot(page, '15-vm-first-launchpad');
+ const password = page.locator('input[type=password]');
+ await password.nth(0).fill('temporary-guidance-password');
+ await expect(vmHint).toHaveText('Passwords do not match');
+ await password.nth(1).fill('temporary-guidance-password');
+ await expect(vmButton).toBeEnabled();
+ await expect(vmButton).toHaveText(/Start .* in a window/);
+ await expect(vmHint).toBeEmpty();
+});
+
+
+test('E2E drive observes the VM-first button and prepares through the real launchpad form', async ({ page }) => {
+ const password = 'temporary-vm-e2e-password';
+ const image = IMAGES[1].imageRef;
+ await boot(page, {
+ mode: 'installer', images: IMAGES, sysinfo: SYSINFO,
+ freshVm: { available: true, probeStatus: 'passed' },
+ driveDirective: JSON.stringify({
+ schemaVersion: 1, runId: 'vm-source-control', directiveId: 'a'.repeat(32),
+ action: 'prepare-vm', image,
+ username: 'alice', password,
+ }),
+ });
+
+ await expect(page.getByRole('heading', { name: 'Linux is starting' })).toBeVisible();
+ const calls = await page.evaluate(() => window.__wootcVMCalls);
+ expect(calls.map(call => call[0])).toEqual(['prepare']);
+ expect(calls[0][1]).toEqual({ imageRef: image, username: 'alice', password });
+ await expect(page.locator('body')).not.toContainText(password);
+
+ await expect.poll(() => page.evaluate(() => window.__wootcE2EReports.some(report =>
+ report.screen === 'launchpad' && report.freshVmAvailable &&
+ report.vmPrepareButtonVisible && !report.vmPrepareButtonDisabled &&
+ !report.vmImageMismatch
+ ))).toBe(true);
+ await expect.poll(() => page.evaluate(() => window.__wootcE2EReports.some(report =>
+ report.schemaVersion === 1 && report.runId === 'vm-source-control' &&
+ report.directiveId === 'a'.repeat(32) && report.action === 'prepare-vm' && report.vmPrepareDriven &&
+ report.screen === 'vmpreview' && report.freshVmAvailable && report.vmReady &&
+ report.vmProgressStage === 'started'
+ ))).toBe(true);
+});
+
+
+test('signed runtime setup returns to account setup without starting an install', async ({ page }) => {
+ await boot(page, { mode: 'installer', images: IMAGES, sysinfo: SYSINFO, freshVm: { available: false, runtimeNeeded: true } });
+ await page.getByRole('button', { name: 'Set up Linux in a window' }).click();
+ await expect(page.locator('#vm-prepare-btn')).toBeVisible();
+ expect(await page.evaluate(() => window.__wootcVMCalls.map(call => call[0]))).toEqual(['runtime']);
+});
+
+test('a release without the runtime gives an explicit error without installing Linux', async ({ page }) => {
+ await boot(page, { mode: 'installer', images: IMAGES, sysinfo: SYSINFO, freshVm: { available: false, runtimeNeeded: true }, runtimeError: 'This release does not include the Linux window runtime yet.
' });
+ await page.getByRole('button', { name: 'Set up Linux in a window' }).click();
+ await expect(page.locator('body')).toContainText('This release does not include the Linux window runtime yet.');
+ await expect(page.locator('body')).toContainText('
');
+ await expect(page.locator('img[src=x]')).toHaveCount(0);
+ expect(await page.evaluate(() => window.__wootcVMCalls.map(call => call[0]))).toEqual(['runtime']);
+ await expect(page.getByRole('button', { name: 'Back', exact: true })).toBeVisible();
+});
+
+
+for (const missing of ['schemaVersion', 'runId', 'directiveId']) {
+ test(`VM preparation drive refuses a directive missing ${missing}`, async ({ page }) => {
+ const directive = { schemaVersion: 1, runId: 'vm-refusal-control', directiveId: 'b'.repeat(32),
+ action: 'prepare-vm', image: IMAGES[1].imageRef, username: 'alice', password: 'disposable-control-password' };
+ delete directive[missing];
+ await boot(page, { mode: 'installer', images: IMAGES, sysinfo: SYSINFO,
+ freshVm: { available: true, probeStatus: 'passed' }, driveDirective: JSON.stringify(directive) });
+ await expect.poll(() => page.evaluate(() => window.__wootcE2EDirectiveReads)).toBeGreaterThanOrEqual(2);
+ expect(await page.evaluate(() => window.__wootcVMCalls)).toEqual([]);
+ expect(await page.evaluate(() => window.__wootcE2EReports)).toEqual([]);
+ await expect(page.locator('#vm-prepare-btn')).toBeVisible();
+ });
+}
+
// Boot repair (#290): the recovery screen shows the OBSERVED boot state, and
// the buttons that write to the boot configuration follow the report — not
// the verdict file, which can be stale.
diff --git a/tests/gui/mock-backend.js b/tests/gui/mock-backend.js
index 011f8119..2b957585 100644
--- a/tests/gui/mock-backend.js
+++ b/tests/gui/mock-backend.js
@@ -6,6 +6,9 @@
// window.runtime.EventsOn/EventsEmit — we implement exactly those.
window.__wootcInstallEmitters = [];
+window.__wootcVMEmitters = [];
+window.__wootcVMCalls = [];
+window.__wootcE2EReports = [];
function makeApp(mock) {
const P = (v) => Promise.resolve(v);
@@ -81,8 +84,18 @@ function makeApp(mock) {
GetUninstallInfo: () => P(mock.uninstall || { found: false }),
UninstallWith: () => P(),
GetVMCapability: () => P(mock.vm || { available: false, reason: '' }),
- BootInVM: () => P(),
+ GetVMState: () => P(mock.vmState || { phase: 'absent', desktopReady: false }),
+ BootInVM: () => { window.__wootcVMCalls.push(['boot']); mock.vmState = { phase: 'running', desktopReady: false }; return P(); },
+ InstallVMRuntime: () => { window.__wootcVMCalls.push(['runtime']); setTimeout(() => { if (mock.runtimeError) { window.__wootcVMEmitters.forEach(cb => cb({ stage: 'error', message: mock.runtimeError })); } else { mock.freshVm = { available: true, probeStatus: 'passed' }; window.__wootcVMEmitters.forEach(cb => cb({ stage: 'runtime-ready', message: 'Checking this PC' })); } }, 20); return P(); },
+ PrepareVM: (cfg) => { window.__wootcVMCalls.push(['prepare', cfg]); mock.vmState = { phase: 'running', desktopReady: false }; setTimeout(() => window.__wootcVMEmitters.forEach(cb => cb({ stage: 'started', message: 'Guest not yet verified' })), 20); return P(); },
+ StopVM: () => { window.__wootcVMCalls.push(['stop']); mock.vmState = { phase: 'stopped', desktopReady: false }; return P(); },
+ ForceStopVM: () => { window.__wootcVMCalls.push(['force']); mock.vmState = { phase: 'needs_recovery', desktopReady: false }; return P(); },
GetFreshVMCapability: () => P(mock.freshVm || { available: false, reason: '' }),
+ E2EDriveDirective: () => { window.__wootcE2EDirectiveReads = (window.__wootcE2EDirectiveReads || 0) + 1; return P(mock.driveDirective || ''); },
+ E2EDriveReport: (state) => {
+ try { window.__wootcE2EReports.push(JSON.parse(state)); } catch {}
+ return P();
+ },
TryInVMFresh: () => P(),
InstallPreviewForReal: () => P(),
DefragDrive: () => { if (mock.defragError) return Promise.reject(mock.defragError); return P(); },
@@ -97,6 +110,7 @@ window.__wootcWindowCalls = [];
window.runtime = {
EventsOnMultiple: (name, cb) => {
if (name === 'install:progress') window.__wootcInstallEmitters.push(cb);
+ if (name === 'vm:progress') window.__wootcVMEmitters.push(cb);
return () => {};
},
EventsOn: (name, cb) => window.runtime.EventsOnMultiple(name, cb, -1),
diff --git a/tests/unit/gui-drive-producer-controls.cjs b/tests/unit/gui-drive-producer-controls.cjs
index fb755726..0a2773de 100644
--- a/tests/unit/gui-drive-producer-controls.cjs
+++ b/tests/unit/gui-drive-producer-controls.cjs
@@ -7,6 +7,9 @@ const source = fs.readFileSync(process.argv[2], 'utf8').replace(/^import .*;\n/,
const image = 'ghcr.io/tuna-os/yellowfin:gnome';
const identity = {schemaVersion: 1, runId: 'current', directiveId: '1234567890abcdef1234567890abcdef'};
let directive = {...identity, action: 'install', image, username: 'wootc', hostname: 'test', password: 'public-test'};
+const installFields = ['schemaVersion', 'runId', 'directiveId', 'action', 'screen', 'installDriven', 'installBtnDisabled', 'hint', 'progressStep', 'error', 'selectedRef', 'imageMismatch'];
+const vmFields = ['vmPrepareDriven', 'freshVmAvailable', 'freshVmReason', 'vmPrepareButtonVisible', 'vmPrepareButtonDisabled', 'vmRuntimeButtonVisible', 'vmImageMismatch', 'vmReady', 'vmProgressStage'];
+const fieldsEqual = (receipt, expected) => assert.deepEqual(Object.keys(receipt).sort(), [...expected].sort());
const reports = [];
let scheduled, clicks = 0, reboots = 0, events = 0;
const button = {disabled: false, click() { clicks++; }};
@@ -38,6 +41,8 @@ async function settle() { await new Promise(resolve => setImmediate(resolve)); }
assert.equal(reports[0].action, 'install'); assert.equal(reports[0].installDriven, true);
state.screen = 'done'; scheduled(); await settle();
assert.equal(reports.at(-1).screen, 'done');
+ fieldsEqual(reports.at(-1), installFields);
+ const installReceipt = reports.at(-1);
const count = reports.length;
directive = {...directive, runId: 'stale'}; scheduled(); await settle();
assert.equal(reports.length, count); assert.equal(clicks, 1); assert.equal(reboots, 0);
@@ -46,6 +51,22 @@ async function settle() { await new Promise(resolve => setImmediate(resolve)); }
directive = {...identity, action: 'reboot'}; scheduled(); await settle();
assert.equal(reboots, 1); assert.equal(reports.at(-1).action, 'reboot');
scheduled(); await settle(); assert.equal(reboots, 1);
+ const rebootReceipt = reports.at(-1);
+ fieldsEqual(rebootReceipt, installFields);
+ const prepareReports = []; let prepareClicks = 0;
+ const prepareContext = {...context, window: {},
+ document: {...context.document, getElementById(id) {
+ return id === 'vm-prepare-btn' ? {disabled: false, click() { prepareClicks++; }} : null;
+ }},
+ E2EDriveDirective: async () => JSON.stringify({...identity, action: 'prepare-vm', image, username: 'wootc', password: 'public-test'}),
+ E2EDriveReport: async raw => prepareReports.push(JSON.parse(raw)),
+ };
+ vm.runInNewContext(source, prepareContext);
+ prepareContext.startE2EDrive({...state, screen: 'launchpad'}); await settle();
+ assert.equal(prepareClicks, 1);
+ const prepareReceipt = prepareReports.at(-1);
+ assert.equal(prepareReceipt.vmPrepareDriven, true);
+ fieldsEqual(prepareReceipt, [...installFields, ...vmFields]);
// A click that throws never reports installDriven and is not replayed.
const failedReports = []; let failedScheduled, failedClicks = 0;
const failedContext = {...context, window: {},
@@ -77,5 +98,5 @@ async function settle() { await new Promise(resolve => setImmediate(resolve)); }
assert.equal(mismatchClicks, 0); assert.equal(mismatchReports.length, 1);
assert.equal(mismatchReports[0].installDriven, false);
assert.equal(mismatchReports[0].imageMismatch, true);
- console.log(JSON.stringify({controls: 8, mismatchReceipt: mismatchReports[0], thrownClickReceipt: failedReports[0], scope: 'actual frontend module with DOM and bridge spies', installReceipt: reports[1]}));
+ console.log(JSON.stringify({controls: 8, mismatchReceipt: mismatchReports[0], thrownClickReceipt: failedReports[0], scope: 'actual frontend module with DOM and bridge spies', installReceipt, rebootReceipt, prepareReceipt, receiptActionControls: 3}));
})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/unit/test_gui_drive_receipt.py b/tests/unit/test_gui_drive_receipt.py
index efa1254a..8b6256d3 100644
--- a/tests/unit/test_gui_drive_receipt.py
+++ b/tests/unit/test_gui_drive_receipt.py
@@ -79,6 +79,43 @@ def test_actual_frontend_produces_bound_install_and_refuses_changed_identity(sel
self.assertNotEqual(result.returncode,0)
self.assertIn('AssertionError',result.stderr)
+ def test_actual_producer_action_schemas_and_strict_host_refusals(self):
+ result = subprocess.run(['node', str(ROOT / 'tests/unit/gui-drive-producer-controls.cjs'),
+ str(ROOT / 'app/frontend/src/lib/e2e.js')], text=True, capture_output=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ proof = json.loads(result.stdout)
+ self.assertEqual(proof['receiptActionControls'], 3)
+ install = proof['installReceipt']
+ reboot = proof['rebootReceipt']
+ prepare = proof['prepareReceipt']
+ self.assertEqual(set(install), module.FIELDS)
+ self.assertEqual(set(reboot), module.FIELDS)
+ self.assertEqual(len(set(prepare) - module.FIELDS), 9)
+ self.assertEqual(prepare['action'], 'prepare-vm')
+ module.validate(json.dumps(install), 'current', DIRECTIVE, IMAGE)
+ for value in (reboot, prepare):
+ with self.assertRaises(ValueError):
+ module.validate(json.dumps(value), 'current', DIRECTIVE, IMAGE)
+ for changes in ({'vmReady': False}, {'action': 'prepare-vm'}):
+ with self.assertRaises(ValueError):
+ module.validate(json.dumps(dict(install, **changes)), 'current', DIRECTIVE, IMAGE)
+ for field in module.FIELDS:
+ missing = dict(install)
+ del missing[field]
+ with self.subTest(missing=field), self.assertRaises(ValueError):
+ module.validate(json.dumps(missing), 'current', DIRECTIVE, IMAGE)
+ # Removing the producer's action guard reintroduces the actual c9 failure.
+ source = (ROOT / 'app/frontend/src/lib/e2e.js').read_text()
+ guard = "if (directive.action === 'prepare-vm') Object.assign(report, {"
+ self.assertIn(guard, source)
+ with tempfile.TemporaryDirectory() as tmp:
+ mutant = Path(tmp) / 'unconditional-report.js'
+ mutant.write_text(source.replace(guard, 'Object.assign(report, {', 1))
+ result = subprocess.run(['node', str(ROOT / 'tests/unit/gui-drive-producer-controls.cjs'),
+ str(mutant)], text=True, capture_output=True)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn('AssertionError', result.stderr)
+
def test_actual_cli_accepts_current_consistent_install(self):
result = subprocess.run(['python3', str(PARSER), RUN, DIRECTIVE, IMAGE],
input=self.receipt(), text=True, capture_output=True)
diff --git a/tests/unit/test_vm_runtime_bundle.py b/tests/unit/test_vm_runtime_bundle.py
new file mode 100644
index 00000000..d3fc806e
--- /dev/null
+++ b/tests/unit/test_vm_runtime_bundle.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""Verify runtime package contents and that fixture private keys stay outside."""
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[2]
+spec = importlib.util.spec_from_file_location("runtime_builder", ROOT / "packaging/build-vm-runtime.py")
+builder = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(builder)
+
+
+class RuntimeBundle(unittest.TestCase):
+ def fixture(self, root):
+ source = root / "source"
+ for name in builder.REQUIRED:
+ file = source / name
+ file.parent.mkdir(parents=True, exist_ok=True)
+ file.write_bytes(b"fixture " + name.encode())
+ for name in ("dependency.dll", "qemu-system-aarch64.exe", "share/edk2-aarch64-code.fd"):
+ (source / name).write_bytes(b"fixture")
+ helper = root / "helper"
+ helper.mkdir()
+ for name in ("builder-vmlinuz", "builder-initramfs.img"):
+ (helper / name).write_bytes(b"test helper")
+ (helper / "builder-protocol.json").write_text(json.dumps({"protocol": "wootc-helper", "protocolVersion": 1}))
+ return source, helper
+
+ def test_archive_contains_only_selected_runtime_and_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source, helper = self.fixture(root)
+ private = root / "seed"
+ private.write_bytes(b"NEVER PUBLISH THIS PRIVATE SEED")
+ runtime = root / "qemu"
+ builder.create_runtime(source, runtime, helper, {"version": "fixture"})
+ manifest = (runtime / "SHA256SUMS").read_text()
+ self.assertIn("dependency.dll", manifest)
+ self.assertIn("share/edk2-x86_64-code.fd", manifest)
+ self.assertIn("builder-initramfs.img", manifest)
+ self.assertNotIn("aarch64", manifest)
+ self.assertNotIn("seed", manifest)
+ (runtime / "SHA256SUMS.sig").write_bytes(b"fixture signature")
+ output = root / "runtime.zip"
+ builder.archive_runtime(runtime, output)
+ with zipfile.ZipFile(output) as archive:
+ self.assertIn("qemu/SHA256SUMS.sig", archive.namelist())
+ self.assertIn("qemu/COPYING", archive.namelist())
+ self.assertFalse(any(name.startswith("/") or ".." in Path(name).parts for name in archive.namelist()))
+ self.assertFalse(any(private.read_bytes() in archive.read(name) for name in archive.namelist()))
+ output2 = root / "runtime2.zip"
+ builder.archive_runtime(runtime, output2)
+ self.assertEqual(output.read_bytes(), output2.read_bytes())
+
+ def test_missing_firmware_and_symlinks_fail(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source, _ = self.fixture(root)
+ (source / "dependency.dll").unlink()
+ (source / "dependency.dll").symlink_to(root / "outside")
+ with self.assertRaises(ValueError):
+ builder.selected_files(source)
+ (source / "dependency.dll").unlink()
+ (source / "share/edk2-i386-vars.fd").unlink()
+ with self.assertRaises(ValueError):
+ builder.selected_files(source)
+
+ def test_bad_installer_hash_rejected_before_extraction(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ installer = root / "installer.exe"
+ installer.write_bytes(b"not the pinned QEMU installer")
+ with self.assertRaisesRegex(ValueError, "SHA-512"):
+ builder.build(installer, root, root / "seed", root / "out.zip", "must-not-execute")
+
+
+if __name__ == "__main__":
+ unittest.main()