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:
133
apps/runner/tests/protocol-client.ts
Normal file
133
apps/runner/tests/protocol-client.ts
Normal 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;
|
||||
}
|
||||
212
apps/runner/tests/protocol-server.test.ts
Normal file
212
apps/runner/tests/protocol-server.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
48
apps/runner/tests/runner-process.integration.test.ts
Normal file
48
apps/runner/tests/runner-process.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
apps/runner/tests/spawn-runner.ts
Normal file
113
apps/runner/tests/spawn-runner.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user