Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e166c11
build: prototype a signed x86 Windows VM runtime bundle
hanthor Sep 26, 2026
e613d01
feat: verify and atomically install complete VM runtime bundles
hanthor Sep 26, 2026
cfd4a12
Manage the persistent Windows VM disk and verified shutdown lifecycle
hanthor Sep 26, 2026
55d0726
Prepare the VM account through private fw_cfg input
hanthor Sep 26, 2026
59c35e1
State which account choices apply to VM preparation
hanthor Sep 26, 2026
d560680
Require observable guest execution before offering the Windows VM
hanthor Sep 26, 2026
8f4fabc
Include the reproducible read-only VM execution probe sector
hanthor Sep 26, 2026
3714cfc
Escape distro names in QEMU option values
hanthor Sep 26, 2026
2f3e42e
Acquire the signed VM runtime with bounded setup and space monitoring
hanthor Sep 26, 2026
4f1e1da
Record native runtime acquisition proof and check error text escaping
hanthor Sep 26, 2026
d2fbfd2
fix(vm): keep probe writes out of signed runtime
hanthor Sep 26, 2026
3ebe455
docs: simplify Windows probe trial note
hanthor Sep 26, 2026
227e4b0
docs: record blocked Windows probe retest
hanthor Sep 27, 2026
c81fa2a
test(vm): drive the VM-first launchpad in GUI E2E
hanthor Sep 27, 2026
f63f8b9
fix(ui): explain disabled VM start action
hanthor Sep 27, 2026
81808d9
docs(gui): show VM-first launchpad guidance
hanthor Sep 27, 2026
883606f
ui: make VM setup requirements readable
hanthor Sep 27, 2026
eefc463
Plan measured smaller VM capacities without changing defaults
hanthor Sep 26, 2026
ffbabd3
Parse separate helper disk minimums without lowering the VM profile
hanthor Sep 26, 2026
ee7a61c
Record native Windows capacity contract checks
hanthor Sep 26, 2026
1294ae3
Record fixture capacity inventory and physical expansion budget
hanthor Sep 26, 2026
f5275bc
Gate experimental VM capacities on exact image and signed runtime ide…
hanthor Sep 26, 2026
80d20be
Bind VM preparation drive receipts to current run and retain integrat…
hanthor Sep 27, 2026
8e1c93e
Retain integrated Go and mock frontend evidence with English gate
hanthor Sep 27, 2026
f692161
Merge commit 'dd837fd6cf6021209c5d74cdb71f5e6dc13288ab' into feat/vm-…
hanthor Sep 27, 2026
baf2d40
Add typed scoped VM display operations on owned QMP pipes
hanthor Sep 27, 2026
fe54516
Merge commit '347f696ef855866e2230dc4e761a1caf11609657' into feat/vm-…
hanthor Sep 27, 2026
2ddd0c6
Bound retained display evidence across VM launches
hanthor Sep 27, 2026
35eb03b
Exercise changed engine ownership and preserve typed shutdown observa…
hanthor Sep 27, 2026
21dd1da
Pin literal typed guest shutdown fields with counterexamples
hanthor Sep 27, 2026
b6c4c6f
Clear inherited desktop flags before any managed VM session
hanthor Sep 27, 2026
4cbdf38
Document the absence of inherited desktop authority
hanthor Sep 27, 2026
c9b6077
Retain scoped QMP native and guard-counterexample provenance
hanthor Sep 27, 2026
658d665
Keep install receipts strict when adding VM preparation observations
hanthor Sep 27, 2026
aa1784c
Exercise preparation receipt through the actual drive loop
hanthor Sep 27, 2026
54287c3
Merge origin/main into feat/vm-scoped-qmp
hanthor Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/vm-qmp-controls.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 24 additions & 5 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 70 additions & 3 deletions app/frontend/src/lib/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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') {
Expand All @@ -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;
Expand Down
20 changes: 16 additions & 4 deletions app/frontend/src/main.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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; }
Expand Down
15 changes: 12 additions & 3 deletions app/frontend/src/screens/control.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -110,11 +119,11 @@ export function renderControlPanel() {
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px">Try Linux in a window</div>
<div style="font-size:11.5px;color:var(--text-muted)">${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}</div>
</div>`;
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;
Expand Down
41 changes: 36 additions & 5 deletions app/frontend/src/screens/launchpad.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)`;
Expand Down
Loading
Loading