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
210 lines
8.4 KiB
TypeScript
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'));
|