minesweeper leaves the platform; plugin sockets relay ws-to-ws

Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper).
Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the
permission registry entry, the App.tsx route and the hand-written websocket
provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3.

The relay changed transport, and that is the part worth reading.

A sidecar announces exactly one port. That single announcement is the whole
provenance argument in target.ts — the platform watched the port arrive at
registration, which is the only thing separating it from a number a manifest
asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so
the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes
have to fall back to the typed command vocabulary over the registration
socket. That is the mechanism the fleet has spent months retiring, and
Minesweeper would have been the first thing to go back to it.

Relaying websocket-to-websocket dissolves the conflict. The sidecar serves
HTTP and /ws on the one port it announced, exactly as the pty sidecar already
does; createSidecarProxy handles the HTTP half; and the conversion from frames
to whatever the plugin actually speaks happens inside the plugin, which is the
only process that knows. The relay is still ignorant of the payload — the
ignorance sits one layer up.

Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use
for it and forwarding one puts a bearer token into whatever that process logs.
X-Officer-User carries the only part it needs. (The terminal relay still
forwards its token to the pty sidecar; not touched here.)

Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check
green.

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:43 +00:00
co-authored by Claude Opus 5
parent 6490dbc368
commit fbdfd3fb89
26 changed files with 96 additions and 1034 deletions
-28
View File
@@ -1,28 +0,0 @@
import { createRouter } from '../../create-router';
import { sessionStatus, startSession, stopSession } from './session';
/**
* Session control. The pixels do not come through here — they come through the websocket relay; this
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
* canvas.
*/
export const minesweeperRouter = createRouter();
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
minesweeperRouter.post('/start', async (c) => {
const status = await sessionStatus();
if (!status.installed) {
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
}
if (status.running) return c.json(status);
const result = await startSession();
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
return c.json(await sessionStatus());
});
minesweeperRouter.post('/stop', async (c) => {
await stopSession();
return c.json(await sessionStatus());
});
-111
View File
@@ -1,111 +0,0 @@
import { join } from 'node:path';
import { OFFICER_ROOT } from '../../data-path';
/**
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
* display on loopback.
*
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
* used by hand. One implementation, so a session started from the browser and one started from a
* terminal are the same session with the same lifecycle.
*/
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
export const VNC_HOST = '127.0.0.1';
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
export type WindowGeometry = { width: number; height: number; x: number; y: number };
export type SessionStatus = {
installed: boolean;
running: boolean;
/** Absent until the game has drawn its window. */
window: WindowGeometry | null;
};
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
const proc = Bun.spawn(cmd, {
cwd: MINESWEEPER_DIR,
env: { ...process.env, ...env },
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, out: `${out}${err}` };
};
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
/**
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
*
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
* until the client speaks or the peek times out. A health check that connects and says nothing is
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
*
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
*/
async function isServing(): Promise<boolean> {
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
const LISTEN = '0A';
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
const text = await Bun.file(table)
.text()
.catch(() => '');
for (const line of text.split('\n').slice(1)) {
const columns = line.trim().split(/\s+/);
const local = columns[1];
const state = columns[3];
if (!local || state !== LISTEN) continue;
if (local.split(':')[1] === wanted) return true;
}
}
return false;
}
/**
* The game window's size, so the browser can size its canvas to the board rather than to the whole
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
*
* The board changes size when the difficulty changes, which is why this is polled rather than read
* once at connect.
*/
export async function windowGeometry(): Promise<WindowGeometry | null> {
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
if (!search.ok) return null;
let best: WindowGeometry | null = null;
for (const id of search.out.trim().split('\n').filter(Boolean)) {
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
if (!geom.ok) continue;
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
if (Object.values(candidate).some(Number.isNaN)) continue;
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
}
return best && best.width > 1 ? best : null;
}
export async function sessionStatus(): Promise<SessionStatus> {
const installed = isInstalled();
if (!installed) return { installed, running: false, window: null };
const running = await isServing();
return { installed, running, window: running ? await windowGeometry() : null };
}
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
-95
View File
@@ -1,95 +0,0 @@
import type { ServerWebSocket, Socket } from 'bun';
import { VNC_HOST, VNC_PORT } from './session';
/**
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
* reading them.
*
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
* and chunks are forwarded exactly as they arrive.
*/
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, 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 closed */
}
};
export const minesweeperWebsocket = {
open(ws: ServerWebSocket<MinesweeperWSData>) {
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
// version string would otherwise lose it.
const relay: Relay = { socket: null, queue: [], done: false };
relays.set(ws, relay);
void Bun.connect({
hostname: VNC_HOST,
port: VNC_PORT,
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 {
/* client gone; the close handler tears down the socket */
}
},
close: () => closeClient(ws, 1000, 'vnc closed'),
error: () => closeClient(ws, 1011, 'vnc error'),
},
}).catch(() => {
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
// panel can say so instead of showing an empty canvas.
closeClient(ws, 1011, 'minesweeper session is not running');
});
},
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
// RFB is binary in both directions; 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<MinesweeperWSData>) {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* ignore */
}
},
};
+7 -5
View File
@@ -4,10 +4,9 @@ import { snapshotPlugins } from '@@/plugins/mount';
/*
Where a plugin's socket actually goes.
A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on
the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a
line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the
relay would have to care what it was carrying.
A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
`ws://<host>:<port>/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
not configurable: every value here has to be one the platform derived, not one a plugin asserted.
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
@@ -15,7 +14,10 @@ import { snapshotPlugins } from '@@/plugins/mount';
to the plugin, and only the second question matters.
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
association is observed rather than asserted.
association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
one and having ordinary routes.
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
+74 -49
View File
@@ -1,4 +1,4 @@
import type { ServerWebSocket, Socket } from 'bun';
import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '@@/auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isApiRequestAllowed } from '@@/permissions/authorize';
@@ -19,26 +19,37 @@ import { pluginWsTargetFor } from './target';
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
plugin name is a path segment rather than a route.
── What it does and does not read ──
── The upstream is a WEBSOCKET, not a raw TCP socket ──
Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the
minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are
forwarded exactly as they arrive.
This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
reason is worth keeping because it is not a preference.
A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of
whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a
line-oriented sidecar without knowing which is which.
**A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
platform watched that port arrive at registration, which is the only thing separating it from a number a
manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
stream and the platform never learns that RFB exists.
This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
boundaries carry no meaning either way. The ignorance simply sits one layer up.
*/
export type PluginWSData = {
provider: 'plugin-ws';
plugin: string;
host: string;
port: number;
/** `ws://127.0.0.1:<announced port>/ws`, with the client's query minus its credential. */
upstream: string;
userId: number;
};
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
type Frame = string | Uint8Array<ArrayBuffer>;
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
@@ -46,7 +57,7 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
relay?.ws?.close();
} catch {
/* already gone */
}
@@ -58,55 +69,63 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
};
export const pluginWsWebsocket = {
async open(ws: ServerWebSocket<PluginWSData>) {
open(ws: ServerWebSocket<PluginWSData>) {
/*
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client
sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that
looks like a corrupt display rather than a lost packet. Learned by dev-proxy first.
already connected and may already be talking before the upstream is open — and an RFB client sends its
protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
a corrupt display rather than a lost packet. Learned by dev-proxy first.
*/
const relay: Relay = { socket: null, queue: [], done: false };
const relay: Relay = { ws: null, queue: [], done: false };
relays.set(ws, relay);
let upstream: WebSocket;
try {
const socket = await Bun.connect({
hostname: ws.data.host,
port: ws.data.port,
socket: {
data: (_s, chunk) => {
try {
ws.send(chunk);
} catch {
/* client gone; the close handler tidies up */
}
},
close: () => closeClient(ws, 1011, 'upstream closed'),
error: () => closeClient(ws, 1011, 'upstream error'),
},
});
// The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
// a request without this header did not come through the platform.
upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
} catch {
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
return;
}
relay.ws = upstream;
upstream.binaryType = 'arraybuffer';
// Between the await above and here the client may have gone. Writing to a socket nobody is reading
// would keep the upstream connection open for as long as the plugin's process lives.
upstream.onopen = () => {
// Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
// the upstream connection open for as long as the plugin's process lives.
if (relay.done) {
socket.end();
try {
upstream.close();
} catch {
/* already gone */
}
return;
}
relay.socket = socket;
for (const chunk of relay.queue) socket.write(chunk);
for (const frame of relay.queue) upstream.send(frame);
relay.queue.length = 0;
} catch {
// A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has
// not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin.
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
}
};
upstream.onmessage = (ev) => {
try {
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
} catch {
/* client gone; the close handler tidies up */
}
};
// A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
// has not booted, or a service the owner stopped. The client is told, and told which plugin.
upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
},
message(ws: ServerWebSocket<PluginWSData>, message: string | Uint8Array) {
message(ws: ServerWebSocket<PluginWSData>, message: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
if (relay.socket) relay.socket.write(bytes);
else relay.queue.push(bytes);
const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array<ArrayBuffer>);
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
else relay.queue.push(frame);
},
close(ws: ServerWebSocket<PluginWSData>) {
@@ -114,7 +133,7 @@ export const pluginWsWebsocket = {
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
relay?.ws?.close();
} catch {
/* already gone */
}
@@ -165,12 +184,18 @@ export async function upgradePluginWs(req: Request, server: any): Promise<Respon
// recipient lookup.
if (!target) return new Response('Not found', { status: 404 });
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
// that process logs. `X-Officer-User` carries the only part it needs.
const forwarded = new URLSearchParams(url.search);
forwarded.delete('token');
const query = forwarded.toString();
const ok = server.upgrade(req, {
data: {
provider: 'plugin-ws',
plugin,
host: target.host,
port: target.port,
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
userId: user.id,
} satisfies PluginWSData,
});
-2
View File
@@ -27,7 +27,6 @@ import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router';
import { subAgentsRouter } from './api/sub-agents/router';
import { caldavRouter } from './api/dav/sidecar-server';
@@ -144,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
+1 -1
View File
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
const surface = () => ({
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
-19
View File
@@ -328,25 +328,6 @@ const CORE_REGISTRY: Permission[] = [
ws: ['task-runner', 'pipeline'],
routes: ['/jobs'],
},
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
//
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
// owner". `plugins/desktop` reached the same conclusion from the same premise.
//
// There is a second reason to keep it here for now: one display and one game means two viewers share a
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
// is cheap for this but is a design decision rather than an implementation detail.
{
key: 'minesweeper',
label: 'Minesweeper',
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
kind: 'execution',
api: ['/minesweeper'],
ws: ['minesweeper'],
routes: ['/minesweeper'],
},
/*
SendOff is `app`, not `confined`, and the distinction is the point.
+11 -5
View File
@@ -285,10 +285,10 @@ export type PluginManifest = {
/**
* A websocket this plugin serves, relayed by the platform.
*
* Declaring this makes `/api/plugin-ws/<appName>` a byte relay onto the address below. It exists because
* Bun's route table is fixed at `serve()`, so a plugin installed afterwards could never add a socket route
* — which is why `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a
* display at all.
* Declaring this makes `/api/plugin-ws/<appName>` a frame relay onto `ws://127.0.0.1:<port>/ws`, where the
* port is the one the named sidecar announced at registration. It exists because Bun's route table is
* fixed at `serve()`, so a plugin installed afterwards could never add a socket route — which is why
* `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a display at all.
*
* Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix.
* There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing
@@ -308,7 +308,13 @@ export type PluginManifest = {
* manifest asserted. Provenance, not a claim.
*
* The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony — it is the thing
* that makes the port mean anything.
* that makes the port mean anything. It costs nothing else: the socket lives at `/ws` on the SAME
* listener the sidecar already serves its routes from, so a plugin does not choose between having a
* socket and having ordinary HTTP.
*
* **The sidecar serves a websocket, not a raw stream.** Whatever it is really talking to — a VNC display,
* a line protocol, another process entirely — the conversion happens inside the plugin, because the
* plugin is the only thing that knows. The platform moves frames it does not read.
*
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
* request-forgery primitive reachable by anyone the plugin is granted to.