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
40 lines
1.6 KiB
TypeScript
40 lines
1.6 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|