feat(runner): WS protocol server with handshake

apps/runner scaffold (app-tier, walking-skeleton story 04): WS server
speaking @repo/core-runner-protocol. Every inbound/outbound frame is
envelope-wrapped and zod-parsed; hello/ready handshake gates on the
workspace-scoped token (constant-time compare, redacted token on
rejection replies); named error events for version/schema/auth
rejections. Config via env only (token never argv); port announced on
stdout for the story-06 provisioner. Runtime deps: ws (the standard
Node WS server; ADR-022 traces do not apply to app-tier) and zod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 22:29:24 +02:00
parent 11e5012572
commit b090e26701
19 changed files with 1331 additions and 11 deletions

View File

@@ -0,0 +1,133 @@
import { once } from "node:events";
import WebSocket from "ws";
import {
envelopeSchema,
PROTOCOL_VERSION,
type Envelope,
type RunnerMessage,
} from "@repo/core-runner-protocol";
/**
* Test-side protocol client. Every inbound frame is parsed against the
* envelope schema — so every assertion made through this client also
* proves the runner's outbound messages are protocol-conformant.
*/
export interface ProtocolClient {
/** Envelope-wrap and send a message (token/version overridable per call). */
send: (
message: RunnerMessage,
overrides?: Partial<Omit<Envelope, "message">>,
) => void;
/** Send a raw frame verbatim (malformed-frame tests). */
sendRaw: (data: string) => void;
/** All inbound messages so far, in arrival order. */
received: () => RunnerMessage[];
/** All inbound frames verbatim (for leak assertions on the raw wire). */
rawReceived: () => string[];
/** Resolve the first (possibly already-received) message matching. */
waitFor: (
predicate: (message: RunnerMessage) => boolean,
description: string,
timeoutMs?: number,
) => Promise<RunnerMessage>;
/** Resolves with the close code once the server closes the socket. */
closed: Promise<number>;
close: () => void;
}
export async function connectProtocolClient(
port: number,
token: string,
): Promise<ProtocolClient> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await once(ws, "open");
const inbound: RunnerMessage[] = [];
const rawFrames: string[] = [];
const waiters = new Set<() => void>();
ws.on("message", (data) => {
const raw = String(data);
rawFrames.push(raw);
const envelope = envelopeSchema.parse(JSON.parse(raw));
inbound.push(envelope.message);
for (const notify of [...waiters]) notify();
});
const closed = new Promise<number>((resolve) => {
ws.on("close", (code) => resolve(code));
});
return {
send: (message, overrides) => {
ws.send(
JSON.stringify({
protocolVersion: PROTOCOL_VERSION,
token,
...overrides,
message,
}),
);
},
sendRaw: (data) => ws.send(data),
received: () => [...inbound],
rawReceived: () => [...rawFrames],
waitFor: (predicate, description, timeoutMs = 30_000) => {
return new Promise<RunnerMessage>((resolve, reject) => {
let scanned = 0;
const scan = (): void => {
while (scanned < inbound.length) {
const message = inbound[scanned];
scanned += 1;
if (message !== undefined && predicate(message)) {
cleanup();
resolve(message);
return;
}
}
};
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`timed out waiting for ${description}; received: ${JSON.stringify(inbound.map((m) => m.type))}`,
),
);
}, timeoutMs);
const cleanup = (): void => {
clearTimeout(timer);
waiters.delete(scan);
};
waiters.add(scan);
scan();
});
},
closed,
close: () => ws.close(),
};
}
/** Complete the hello/ready handshake and return the ready message. */
export async function handshake(
client: ProtocolClient,
): Promise<RunnerMessage> {
client.send({ type: "hello" });
return client.waitFor((m) => m.type === "ready", "ready handshake reply");
}
/** Wait for the next `error` event and assert its named cause. */
export async function expectNamedError(
client: ProtocolClient,
cause: string,
): Promise<Extract<RunnerMessage, { type: "error" }>> {
const error = await client.waitFor(
(m) => m.type === "error",
`${cause} error`,
);
if (error.type !== "error" || error.cause !== cause) {
throw new Error(
`expected a "${cause}" error, received: ${JSON.stringify(error)}`,
);
}
return error;
}

View File

@@ -0,0 +1,212 @@
/**
* Protocol-server suite over a real WS connection (in-process server so
* v8 coverage sees `server.ts`; the spawn suite in
* `runner-process.integration.test.ts` proves the same handshake against
* a real child process). Assertions go through the protocol only — every
* inbound frame the client records is envelope-parsed, so runner
* outbound conformance is asserted implicitly on every test.
*/
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
import { startRunnerServer, type RunnerServer } from "@/server";
import { createLogger } from "@/log";
import {
connectProtocolClient,
expectNamedError,
handshake,
type ProtocolClient,
} from "./protocol-client";
const TOKEN = "test-workspace-token";
let server: RunnerServer;
let workspaceDir: string;
let logLines: string[];
let clients: ProtocolClient[];
async function connect(token = TOKEN): Promise<ProtocolClient> {
const client = await connectProtocolClient(server.port, token);
clients.push(client);
return client;
}
beforeEach(async () => {
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "veect-runner-test-"));
logLines = [];
clients = [];
server = await startRunnerServer({
host: "127.0.0.1",
port: 0,
token: TOKEN,
workspaceDir,
heartbeatMs: 50,
log: createLogger((line) => logLines.push(line)),
});
});
afterEach(async () => {
for (const client of clients) client.close();
await server.close();
await rm(workspaceDir, { recursive: true, force: true });
});
describe("hello/ready handshake", () => {
it("answers a valid hello with ready", async () => {
const client = await connect();
const ready = await handshake(client);
expect(ready).toEqual({ type: "ready" });
});
it.each([
[
"a bad token",
"unauthorized",
(client: ProtocolClient) => client.send({ type: "hello" }),
"wrong-token",
],
[
"a missing token (schema)",
"invalid-message",
(client: ProtocolClient) =>
client.sendRaw(
JSON.stringify({
protocolVersion: PROTOCOL_VERSION,
message: { type: "hello" },
}),
),
TOKEN,
],
[
"an unsupported protocol version",
"unsupported-protocol-version",
(client: ProtocolClient) =>
client.send({ type: "hello" }, { protocolVersion: "42" as never }),
TOKEN,
],
[
"a non-JSON frame",
"invalid-message",
(client: ProtocolClient) => client.sendRaw("definitely not json"),
TOKEN,
],
] as const)(
"rejects %s with a named %s error and closes 1008",
async (_label, cause, act, token) => {
const client = await connect(token);
act(client);
await expectNamedError(client, cause);
await expect(client.closed).resolves.toBe(1008);
},
);
it("requires hello first: a command before the handshake is rejected", async () => {
const client = await connect();
client.send({ type: "install" });
const error = await expectNamedError(client, "invalid-message");
expect(error.message).toContain("handshake required");
await expect(client.closed).resolves.toBe(1008);
});
it("redacts the workspace token on replies to unauthenticated peers", async () => {
const client = await connect("wrong-token");
client.send({ type: "hello" });
await expectNamedError(client, "unauthorized");
// The rejection envelope must not carry the real workspace token.
expect(client.rawReceived().join("")).not.toContain(TOKEN);
expect(client.rawReceived().join("")).toContain('"redacted"');
await expect(client.closed).resolves.toBe(1008);
});
it("answers a repeated hello idempotently with ready", async () => {
const client = await connect();
await handshake(client);
client.send({ type: "hello" });
const second = await client.waitFor(
(m) =>
m.type === "ready" &&
client.received().filter((r) => r.type === "ready").length >= 2,
"second ready",
);
expect(second).toEqual({ type: "ready" });
});
});
describe("post-handshake message gate", () => {
it("keeps the session open after a malformed frame post-handshake", async () => {
const client = await connect();
await handshake(client);
client.sendRaw("{broken");
await expectNamedError(client, "invalid-message");
// Session survives: another hello still gets a ready.
client.send({ type: "hello" });
const ready = await client.waitFor(
(m) =>
m.type === "ready" &&
client.received().filter((r) => r.type === "ready").length >= 2,
"ready after malformed frame",
);
expect(ready).toEqual({ type: "ready" });
});
it("closes the session on a bad token even post-handshake", async () => {
const client = await connect();
await handshake(client);
client.send({ type: "install" }, { token: "tampered" });
await expectNamedError(client, "unauthorized");
await expect(client.closed).resolves.toBe(1008);
});
it("rejects runner-emitted event types sent by the client", async () => {
const client = await connect();
await handshake(client);
client.send({ type: "status", stage: "cloning", elapsedMs: 1 });
const error = await expectNamedError(client, "invalid-message");
expect(error.message).toContain("runner-emitted");
});
it("rejects a strict-schema violation without echoing payload values", async () => {
const client = await connect();
await handshake(client);
client.sendRaw(
JSON.stringify({
protocolVersion: PROTOCOL_VERSION,
token: TOKEN,
message: {
type: "clone",
gitUrl: "git://x/y.git",
pat: "sekrit-pat",
extra: true,
},
}),
);
const error = await expectNamedError(client, "invalid-message");
expect(JSON.stringify(error)).not.toContain("sekrit-pat");
expect(logLines.join("\n")).not.toContain("sekrit-pat");
});
});
describe("commands not yet wired (stories 05+)", () => {
it.each([
["scan", "scan-failed", "scanning"],
["adapter-start", "adapter-start-failed", "starting-preview"],
] as const)(
"answers %s with the named %s error",
async (type, cause, stage) => {
const client = await connect();
await handshake(client);
client.send({ type } as never);
const error = await expectNamedError(client, cause);
expect(error).toMatchObject({ type: "error", cause, stage });
},
);
it("answers render-frame with the named render-failed error", async () => {
const client = await connect();
await handshake(client);
client.send({ type: "render-frame", componentId: "button" });
await expectNamedError(client, "render-failed");
});
});

View File

@@ -0,0 +1,48 @@
/**
* Spawn-based integration suite: the runner as the provisioner (story 06)
* will actually run it — a real child process, config via env only, port
* discovered from stdout, real WS from the outside.
*/
import { afterEach, describe, expect, it } from "vitest";
import { spawnRunner, type SpawnedRunner } from "./spawn-runner";
import {
connectProtocolClient,
expectNamedError,
handshake,
type ProtocolClient,
} from "./protocol-client";
const TOKEN = "spawned-runner-token";
let runner: SpawnedRunner | undefined;
let clients: ProtocolClient[] = [];
afterEach(async () => {
for (const client of clients) client.close();
clients = [];
await runner?.stop();
runner = undefined;
});
describe("spawned runner process", () => {
it("boots from env config, announces its port, and completes the handshake over real WS", async () => {
runner = await spawnRunner({ token: TOKEN });
expect(runner.port).toBeGreaterThan(0);
const client = await connectProtocolClient(runner.port, TOKEN);
clients.push(client);
const ready = await handshake(client);
expect(ready).toEqual({ type: "ready" });
});
it("rejects a bad workspace token from a real client and closes the socket", async () => {
runner = await spawnRunner({ token: TOKEN });
const client = await connectProtocolClient(runner.port, "not-the-token");
clients.push(client);
client.send({ type: "hello" });
await expectNamedError(client, "unauthorized");
await expect(client.closed).resolves.toBe(1008);
// The runner never writes the workspace token to its logs.
expect(runner.output()).not.toContain(TOKEN);
});
});

View File

@@ -0,0 +1,113 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const APP_ROOT = fileURLToPath(new URL("..", import.meta.url));
const START_TIMEOUT_MS = 60_000;
const KILL_TIMEOUT_MS = 5_000;
/**
* A real runner child process, as the provisioner (story 06) will spawn
* it: config via env only, port discovered from the stdout `listening`
* line. `output()` exposes everything the process ever wrote — the
* credential-leak assertions grep it.
*/
export interface SpawnedRunner {
port: number;
workspaceDir: string;
/** Combined stdout + stderr captured so far. */
output: () => string;
stop: () => Promise<void>;
}
export async function spawnRunner(options: {
token: string;
}): Promise<SpawnedRunner> {
const workspaceDir = await mkdtemp(
path.join(os.tmpdir(), "veect-runner-ws-"),
);
const child: ChildProcess = spawn(
process.execPath,
["--import", "tsx", "src/main.ts"],
{
cwd: APP_ROOT,
env: {
...process.env,
RUNNER_TOKEN: options.token,
RUNNER_WORKSPACE_DIR: workspaceDir,
RUNNER_PORT: "0",
RUNNER_HEARTBEAT_MS: "250",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let captured = "";
child.stdout?.on("data", (chunk: Buffer) => {
captured += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
captured += chunk.toString("utf8");
});
const stop = async (): Promise<void> => {
if (child.exitCode === null && child.signalCode === null) {
const exited = new Promise<void>((resolve) =>
child.once("exit", () => resolve()),
);
const killTimer = setTimeout(
() => child.kill("SIGKILL"),
KILL_TIMEOUT_MS,
);
killTimer.unref();
child.kill("SIGTERM");
await exited;
clearTimeout(killTimer);
}
await rm(workspaceDir, { recursive: true, force: true });
};
const port = await new Promise<number>((resolve, reject) => {
const deadline = setTimeout(() => {
reject(
new Error(
`runner did not announce a port within ${START_TIMEOUT_MS}ms:\n${captured}`,
),
);
}, START_TIMEOUT_MS);
const poll = setInterval(() => {
if (child.exitCode !== null) {
clearTimeout(deadline);
clearInterval(poll);
reject(
new Error(
`runner exited before listening (code ${child.exitCode}):\n${captured}`,
),
);
return;
}
for (const line of captured.split("\n")) {
if (!line.includes('"listening"')) continue;
try {
const parsed = JSON.parse(line) as { event?: string; port?: number };
if (parsed.event === "listening" && typeof parsed.port === "number") {
clearTimeout(deadline);
clearInterval(poll);
resolve(parsed.port);
return;
}
} catch {
// partial line — keep polling
}
}
}, 50);
}).catch(async (error: unknown) => {
await stop();
throw error;
});
return { port, workspaceDir, output: () => captured, stop };
}