minesweeper: extracted from the platform

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
This commit is contained in:
2026-08-23 17:43:17 +00:00
co-authored by Claude Opus 5
commit f3cc1242c4
14 changed files with 1533 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* Running a command and waiting for a condition — the two things every step below needs.
*
* The waiting matters more than it looks. Every readiness check here polls for an OBSERVABLE FACT rather
* than trusting a live pid, because each of these processes can be alive and not yet usable: Xvfb forks
* before its socket answers, wine returns while the window is still being created, and x11vnc exits after
* printing an error if the display went away. A pid check passes for all three and the next step then fails
* in a way that reads as the game being broken.
*/
export type RunResult = { ok: boolean; stdout: string; stderr: string; code: number };
export async function run(cmd: string[], env: Record<string, string> = {}, timeoutMs = 30_000): Promise<RunResult> {
const proc = Bun.spawn(cmd, {
env: { ...process.env, ...env },
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});
const killer = setTimeout(() => proc.kill('SIGKILL'), timeoutMs);
try {
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, stdout, stderr, code };
} finally {
clearTimeout(killer);
}
}
/** Poll `check` until it is true. Resolves false on timeout rather than throwing — every caller reports. */
export async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs: number, everyMs = 250) {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await check()) return true;
if (Date.now() >= deadline) return false;
await Bun.sleep(everyMs);
}
}