Minesweeper X under wine on a headless display, as a plugin. It was /api/minesweeper, a Dashboard screen, an officerdev panel, a permission registry entry and a hand-written websocket provider in server.tsx. The socket is why it had to live in the platform: Bun's route table is fixed at serve(), so nothing installed later could add one. That ceiling is gone, and this is the first plugin through the per-plugin relay. Three things changed rather than moved: The sidecar owns the session. The platform used to spawn bin/session.sh detached, so the game outlived every restart, nothing supervised it, and a crashed run left wine processes holding the prefix — the next start hung rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k' scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed every other wine prefix on the machine. x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory. -nopw was only ever safe because of the machine, not the platform: any local account could open that port and take the display. A fixed port is squattable too. There is now no listener to authenticate against. The wine prefix is confined to C:. This is the one that mattered. The platform classified Minesweeper as kind:'execution' — never grantable — because a fresh prefix maps the whole filesystem to Z: and symlinks the profile into the owner's real home, and the game has Open and Save dialogs. A plugin permission has no kind and is always grantable, so extracting it without this would have converted a sealed capability into a file picker running as the owner. Re-asserted on every start, because wine restores z: whenever it updates a prefix. Not solved: one display and one game, so two grantees share a board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
148 lines
7.1 KiB
TypeScript
148 lines
7.1 KiB
TypeScript
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { WINEPREFIX } from './paths';
|
|
import { run } from './run';
|
|
|
|
/**
|
|
* Building the wine prefix, once, on first start.
|
|
*
|
|
* ── Why this is code and not an install step ──
|
|
*
|
|
* The plugin system has no install hook, and it should not grow one for this: an install that takes four
|
|
* minutes and downloads from Microsoft is not something to do behind a progress bar the owner cannot
|
|
* cancel. Doing it inside the first `POST /start` means it happens when somebody actually wants to play,
|
|
* it can report what it is doing, and a machine that installs the plugin and never opens it pays nothing.
|
|
*
|
|
* It is also self-healing. Deleting the prefix — which is the standard fix for a wedged wine — is safe,
|
|
* because the next start rebuilds it.
|
|
*
|
|
* ── The two facts that decide the whole setup ──
|
|
*
|
|
* Minesweeper X is a 68 KB i386 PE (machine `0x014c`, verified), so the prefix must be `win32`. A 64-bit
|
|
* prefix cannot load it and the failure is a bare `Bad EXE format`.
|
|
*
|
|
* It was built with Visual Basic 6, so it needs `MSVBVM60.DLL`, which wine does not ship. Without it the
|
|
* process exits immediately having printed one line — `err:module:import_dll Library MSVBVM60.DLL not
|
|
* found` — and nothing else. That is a quiet enough failure that it is worth checking for the DLL directly
|
|
* rather than inferring readiness from the prefix existing.
|
|
*/
|
|
|
|
/** The one file whose presence means this prefix can actually run the game. */
|
|
const VB6_RUNTIME = join(WINEPREFIX, 'drive_c', 'windows', 'system32', 'msvbvm60.dll');
|
|
|
|
export const prefixReady = (): boolean => existsSync(VB6_RUNTIME);
|
|
|
|
export const WINE_ENV = {
|
|
WINEPREFIX,
|
|
WINEARCH: 'win32',
|
|
// wine narrates every unimplemented call otherwise, and the game triggers thousands.
|
|
WINEDEBUG: '-all',
|
|
// Never prompt to install .NET or an embedded IE. The dialog appears on a display nobody is watching,
|
|
// and the start then hangs until it times out.
|
|
WINEDLLOVERRIDES: 'mscoree,mshtml=',
|
|
};
|
|
|
|
export type PrefixResult = { ok: boolean; detail: string };
|
|
|
|
/**
|
|
* Create the prefix if it is not there, then install the VB6 runtime into it.
|
|
*
|
|
* `winetricks -q vb6run` fetches Microsoft's own `VB6.0-KB290887-X86.exe` and verifies its checksum before
|
|
* unpacking, which is why this does not carry a redistributable of its own.
|
|
*/
|
|
export async function ensurePrefix(log: (line: string) => void): Promise<PrefixResult> {
|
|
// Before the early return as well: an existing prefix may predate this, or have had `z:` restored by a
|
|
// wine upgrade. It is a few `unlink` calls, so it is cheaper to re-assert than to detect.
|
|
if (prefixReady()) {
|
|
confinePrefix();
|
|
return { ok: true, detail: 'prefix ready' };
|
|
}
|
|
|
|
if (!Bun.which('wine')) {
|
|
return { ok: false, detail: 'wine is not installed. On Debian or Ubuntu this also needs i386 multiarch: `sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install wine wine32:i386`' };
|
|
}
|
|
if (!Bun.which('winetricks')) {
|
|
return { ok: false, detail: 'winetricks is not installed, and it is what fetches the Visual Basic 6 runtime the game needs' };
|
|
}
|
|
|
|
if (!existsSync(WINEPREFIX)) {
|
|
log('creating the 32-bit wine prefix (first run only, this takes a minute)');
|
|
// `wineboot -i` rather than letting the first wine call create it implicitly: implicit creation happens
|
|
// inside the game launch, races the window-detection poll, and looks like the game failing to start.
|
|
const boot = await run(['wineboot', '-i'], WINE_ENV, 300_000);
|
|
if (!boot.ok && !existsSync(WINEPREFIX)) {
|
|
return { ok: false, detail: `wineboot failed: ${(boot.stderr || boot.stdout).trim().slice(-400)}` };
|
|
}
|
|
}
|
|
|
|
log('installing the Visual Basic 6 runtime into the prefix (downloads from Microsoft)');
|
|
const tricks = await run(['winetricks', '-q', 'vb6run'], WINE_ENV, 600_000);
|
|
|
|
// winetricks exits 0 having done nothing often enough that the exit code is not the answer. The file is.
|
|
if (!prefixReady()) {
|
|
return {
|
|
ok: false,
|
|
detail: `the VB6 runtime did not install; msvbvm60.dll is still missing. winetricks said: ${(tricks.stderr || tricks.stdout).trim().slice(-400)}`,
|
|
};
|
|
}
|
|
|
|
// Last, not first: `winetricks` runs wine, and wine recreates `z:` on the way past.
|
|
confinePrefix();
|
|
|
|
log('prefix ready and confined to drive C');
|
|
return { ok: true, detail: 'prefix built' };
|
|
}
|
|
|
|
/**
|
|
* Cut the prefix off from the rest of the machine.
|
|
*
|
|
* ── Why this exists, and why it is not optional ──
|
|
*
|
|
* A fresh wine prefix is not a sandbox. `wineboot` creates `dosdevices/z: -> /`, which maps the ENTIRE
|
|
* filesystem to a drive letter, and symlinks the profile's Documents, Downloads and Music straight into the
|
|
* owner's real home. Minesweeper X has a replay feature with Open and Save dialogs, so anyone looking at the
|
|
* display gets a file browser over all of that, running as the server owner.
|
|
*
|
|
* That is precisely why the platform's permission registry classified Minesweeper as `execution` — a kind
|
|
* that can never be granted to anyone. But **a plugin permission is always grantable**, by design and with
|
|
* no field to opt out of. So extracting this feature without also fixing the prefix would have converted a
|
|
* structurally ungrantable capability into one an owner could hand to a Member, complete with a file picker
|
|
* over the owner's home. The extraction is what made this urgent; the hole was always there.
|
|
*
|
|
* `docs/affordances.md` has the rule that applies: if it can be fixed instead of documented, fix it. A note
|
|
* saying "do not grant this" outlives everyone's memory of why.
|
|
*
|
|
* ── What is left ──
|
|
*
|
|
* `C:` alone, pointing at the prefix. The game keeps its own directory, its settings and its replays; it can
|
|
* see nothing else. Serial and parallel device nodes go too — a 1997 puzzle game has no business holding an
|
|
* open descriptor on `/dev/ttyS0`.
|
|
*
|
|
* Re-asserted on every start rather than only at creation, because wine recreates `z:` whenever it decides
|
|
* the prefix needs updating — a wine package upgrade is enough, and it would restore the hole silently.
|
|
*/
|
|
export function confinePrefix(): void {
|
|
const dosdevices = join(WINEPREFIX, 'dosdevices');
|
|
if (existsSync(dosdevices)) {
|
|
for (const entry of readdirSync(dosdevices)) {
|
|
// Keep `c:` and only `c:`. Everything else is a door out of the prefix or a device node.
|
|
if (entry.toLowerCase() === 'c:') continue;
|
|
rmSync(join(dosdevices, entry), { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
const users = join(WINEPREFIX, 'drive_c', 'users');
|
|
if (!existsSync(users)) return;
|
|
for (const user of readdirSync(users)) {
|
|
const profile = join(users, user);
|
|
for (const folder of readdirSync(profile)) {
|
|
const path = join(profile, folder);
|
|
// A symlink here points into the real home. Replace it with an ordinary directory so the game still
|
|
// has somewhere to save a replay, and that somewhere is inside the prefix.
|
|
if (!lstatSync(path).isSymbolicLink()) continue;
|
|
rmSync(path, { force: true });
|
|
mkdirSync(path, { recursive: true });
|
|
}
|
|
}
|
|
}
|