M1 (headless core) of the VirtualHere passthrough plugin: operator-authored rules bracket a
USB-over-IP binding around a punktfunk session, so a wheel/HOTAS/pad on the couch shows up on
the host while streaming and returns the moment it stops.
Integration only — nothing from VirtualHere is vendored, bundled or downloaded; the plugin
drives a client the operator installs and licenses themselves.
The transport is platform-split, on M0 spike evidence (2026-07-30, RTX box, client v6.0.2):
- Windows speaks \\.\pipe\vhclient directly. LocalService — the runner's principal —
round-trips it (HELP 2166 B, LIST 258 B, identical to an admin baseline), while the
vendor's `vhui64.exe -t` wrapper hangs forever (0 bytes, both LocalService and the
interactive console session). Each pipe connection gets its own instance, so
request/response is correlated; safe.
- Linux/macOS shells out to `-t`, because there the IPC is two FIFOs and the response FIFO
is an uncorrelated global — reading it ourselves would steal other consumers' replies.
Never stranding a device is the core promise, so it is layered: the journal is written
BEFORE every USE (a crash leaves a superset of reality, never a subset), reconcile-on-start
unconditionally hands back everything the journal names, plus a watchdog and a release on
clean shutdown.
Ownership is tracked per binding rather than as a flag: only the owning trigger's end
releases, so a second couch's stream stopping cannot hand back the first couch's wheel.
Security: only the operator names devices — nothing on the wire influences which device
binds. Attaching USB to the host is an input-trust escalation, and a couch that could name
its own device could attach a virtual keyboard. Addresses are regex-validated before they
reach a verb and the exec backend passes argv, never a shell string.
28 tests cover the parser, matcher, ownership policy and the pipe framing (over a unix
socket, so it runs on any CI host). Design: planning/design/virtualhere-plugin.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
// The Windows transport's framing, exercised over a unix socket so it runs on any CI host.
|
|
//
|
|
// This is the riskiest code in the plugin: VirtualHere does not frame its replies and does
|
|
// not close the connection per command, so "the reply stopped arriving" is the only end
|
|
// marker available (see pipe.ts). These tests pin that behaviour — chunked replies must be
|
|
// reassembled whole, and a client that never answers must fail rather than hang a fiber.
|
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as net from "node:net";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Effect, Exit } from "effect";
|
|
import { makePipeBackend } from "../src/ipc/pipe.js";
|
|
|
|
const sockets: net.Server[] = [];
|
|
const paths: string[] = [];
|
|
|
|
const listen = (
|
|
onLine: (line: string, socket: net.Socket) => void,
|
|
): Promise<string> => {
|
|
const p = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "vh-test-")),
|
|
"sock",
|
|
);
|
|
paths.push(p);
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer((socket) => {
|
|
socket.on("data", (buf) => onLine(buf.toString("utf8"), socket));
|
|
});
|
|
sockets.push(server);
|
|
server.listen(p, () => resolve(p));
|
|
});
|
|
};
|
|
|
|
afterEach(() => {
|
|
for (const s of sockets.splice(0)) s.close();
|
|
for (const p of paths.splice(0))
|
|
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
});
|
|
|
|
describe("pipe backend", () => {
|
|
test("sends the verb newline-terminated and returns the reply", async () => {
|
|
let seen = "";
|
|
const p = await listen((line, socket) => {
|
|
seen = line;
|
|
socket.write("OK\n");
|
|
});
|
|
const reply = await Effect.runPromise(makePipeBackend(p).send("LIST"));
|
|
expect(seen).toBe("LIST\n");
|
|
expect(reply).toBe("OK\n");
|
|
});
|
|
|
|
test("reassembles a reply split across chunks", async () => {
|
|
// VirtualHere's HELP reply is ~2 KB and arrives in pieces; a single read would
|
|
// truncate it.
|
|
const p = await listen((_line, socket) => {
|
|
socket.write("VirtualHere Client IPC, below are");
|
|
setTimeout(() => socket.write(" the available devices:\n"), 20);
|
|
setTimeout(() => socket.write("Auto-Find currently on\n"), 40);
|
|
});
|
|
const reply = await Effect.runPromise(makePipeBackend(p).send("LIST"));
|
|
expect(reply).toBe(
|
|
"VirtualHere Client IPC, below are the available devices:\nAuto-Find currently on\n",
|
|
);
|
|
});
|
|
|
|
test("a server that closes first still yields the full reply", async () => {
|
|
const p = await listen((_line, socket) => {
|
|
socket.write("OK\n");
|
|
socket.end();
|
|
});
|
|
expect(await Effect.runPromise(makePipeBackend(p).send("USE,a.1"))).toBe(
|
|
"OK\n",
|
|
);
|
|
});
|
|
|
|
test("an unreachable pipe fails instead of hanging", async () => {
|
|
// The M0 spike's `-t` wrapper hung forever; this transport must never do that.
|
|
const exit = await Effect.runPromiseExit(
|
|
makePipeBackend(path.join(os.tmpdir(), "vh-does-not-exist.sock")).send(
|
|
"LIST",
|
|
),
|
|
);
|
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
});
|
|
});
|