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:
@@ -0,0 +1,209 @@
|
||||
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'));
|
||||
Reference in New Issue
Block a user