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