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
+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,
});