Files
minesweeper/sidecar/index.ts
T
pastilhasandClaude Opus 5 f3cc1242c4 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
2026-08-23 17:43:17 +00:00

210 lines
8.4 KiB
TypeScript

import type { ServerWebSocket, Socket } from 'bun';
import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { VNC_SOCKET, ensureDirs } from './paths';
import { isRunning, start, status, stop } from './session';
/*
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
─────────────────────────────────────────────────────────────────────────────────────────────────
HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding.
GET /status { installed, running, window, error? }
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
POST /stop takes it down
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
─────────────────────────────────────────────────────────────────────────────────────────────────
WHY ONE PORT SERVES BOTH
A sidecar announces exactly one port, and that single announcement is the entire reason the platform
trusts it: the port was observed arriving at registration rather than asserted by a manifest. So `/ws`
lives here, beside the routes, on the listener that was announced — and the platform's plugin socket
relay moves frames onto it without knowing that RFB exists.
The conversion from frames to a byte stream happens HERE, in the only process that knows what it is
carrying. That is the whole shape: the platform authenticates and relays; the plugin speaks its protocol.
─────────────────────────────────────────────────────────────────────────────────────────────────
WHAT REACHES x11vnc
Nothing but this file. x11vnc binds a socket inside a mode-0700 directory and has no port at all, so the
kernel refuses every other account before any credential could be checked. That is why `-nopw` is right
rather than lazy — see the block comment in `session.ts` where it is spawned.
This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform
is the authenticator and `X-Officer-User` is how a request proves it came through one.
*/
const NAME = 'minesweeper';
const log = (line: string) => console.log(`[${NAME}] ${line}`);
ensureDirs();
/** An ephemeral free port, grabbed by briefly binding one. Announced at registration; never written down. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
type WsData = { userId: number };
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
const relays = new Map<ServerWebSocket<WsData>, Relay>();
const teardown = (ws: ServerWebSocket<WsData>, code: number, reason: string) => {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* already gone */
}
try {
ws.close(code, reason);
} catch {
/* already closing */
}
};
const server = Bun.serve({
port,
hostname: '127.0.0.1',
async fetch(req, srv) {
const url = new URL(req.url);
// Loopback plus this header is the trust signal every sidecar here uses: a request without it did not
// come through the platform, and the platform is what decided the caller may play.
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/ws') {
if (srv.upgrade(req, { data: { userId } satisfies WsData })) return undefined as unknown as Response;
return new Response('expected a websocket upgrade', { status: 426 });
}
try {
if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status());
if (req.method === 'POST' && url.pathname === '/start') {
const result = await start(log);
// 503 rather than 500: the session is unavailable and the reason is in the body, which is what the
// panel shows instead of an empty canvas.
return Response.json(result, { status: result.running ? 200 : 503 });
}
if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop());
} catch (err) {
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
websocket: {
open(ws: ServerWebSocket<WsData>) {
/*
The queue is not an optimisation. The client is connected the moment the upgrade returns and an RFB
client sends its protocol version immediately, while `Bun.connect` is still resolving. Dropping
those bytes desynchronises the handshake in a way that renders as a corrupt display rather than as
a lost packet.
*/
const relay: Relay = { socket: null, queue: [], done: false };
relays.set(ws, relay);
if (!isRunning()) {
teardown(ws, 1011, 'the session is not running');
return;
}
void Bun.connect({
unix: VNC_SOCKET,
socket: {
open(socket) {
if (relay.done) {
socket.end();
return;
}
relay.socket = socket;
for (const chunk of relay.queue) socket.write(chunk);
relay.queue.length = 0;
},
data(_socket, chunk) {
try {
ws.send(chunk);
} catch {
/* viewer gone; close tidies up */
}
},
close: () => teardown(ws, 1000, 'display closed'),
error: () => teardown(ws, 1011, 'display error'),
},
}).catch(() => teardown(ws, 1011, 'could not reach the display'));
},
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
// RFB is binary both ways; a text frame is a client that is not speaking it.
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
if (relay.socket) relay.socket.write(bytes);
else relay.queue.push(bytes);
},
close(ws: ServerWebSocket<WsData>) {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* already gone */
}
},
},
});
log(`listening on 127.0.0.1:${port}`);
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: NAME,
handles: [NAME],
onCommand(cmd, reply) {
const c = cmd as { type: string; id?: string };
if (c.type === 'ping') reply({ type: 'pong', id: c.id } as never);
else reply({ type: 'error', id: c.id, error: `Unknown command type: ${c.type}` } as never);
},
onConnected() {
// The announcement the plugin websocket relay resolves against. One port, both halves.
connection.send({ type: `${NAME}:server`, port } as never);
log(`reported server port ${port} to the platform`);
},
});
async function shutdown(signal: string) {
log(`${signal} received, shutting down`);
try {
server.stop(true);
} catch {
/* already stopped */
}
// The game is not left running. It is a child of this process, so PM2 would reach it anyway — but wine's
// own daemons are not, and leaving those holding the prefix is what makes the NEXT start hang.
await stop().catch(() => undefined);
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));