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:
66
apps/runner/src/config.ts
Normal file
66
apps/runner/src/config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Runner process configuration, sourced from the environment by the
|
||||
* provisioner (walking-skeleton story 06) or by the integration tests
|
||||
* that spawn the runner directly.
|
||||
*
|
||||
* - `RUNNER_TOKEN` (required) — the workspace-scoped auth token every
|
||||
* protocol envelope is gated on. A secret: env only, never argv.
|
||||
* - `RUNNER_WORKSPACE_DIR` (required) — the directory this runner owns;
|
||||
* the clone lands at `<workspaceDir>/repo`.
|
||||
* - `RUNNER_PORT` (default `0`) — WS listen port; `0` binds an ephemeral
|
||||
* port which `main.ts` announces on stdout (`event: "listening"`).
|
||||
* - `RUNNER_HOST` (default `127.0.0.1`) — WS listen host.
|
||||
* - `RUNNER_HEARTBEAT_MS` (default `1000`) — staged-progress heartbeat
|
||||
* interval while a stage is running.
|
||||
*/
|
||||
const runnerConfigSchema = z
|
||||
.object({
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
token: z.string().min(1),
|
||||
workspaceDir: z.string().min(1),
|
||||
heartbeatMs: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type RunnerConfig = z.infer<typeof runnerConfigSchema>;
|
||||
|
||||
function parseIntegerEnv(
|
||||
name: string,
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error(`${name} must be an integer, received "${raw}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function loadConfigFromEnv(env: NodeJS.ProcessEnv): RunnerConfig {
|
||||
const token = env.RUNNER_TOKEN;
|
||||
if (token === undefined || token === "") {
|
||||
throw new Error("RUNNER_TOKEN is required (workspace-scoped auth token)");
|
||||
}
|
||||
const workspaceDir = env.RUNNER_WORKSPACE_DIR;
|
||||
if (workspaceDir === undefined || workspaceDir === "") {
|
||||
throw new Error("RUNNER_WORKSPACE_DIR is required");
|
||||
}
|
||||
return runnerConfigSchema.parse({
|
||||
host:
|
||||
env.RUNNER_HOST === undefined || env.RUNNER_HOST === ""
|
||||
? "127.0.0.1"
|
||||
: env.RUNNER_HOST,
|
||||
port: parseIntegerEnv("RUNNER_PORT", env.RUNNER_PORT, 0),
|
||||
token,
|
||||
workspaceDir,
|
||||
heartbeatMs: parseIntegerEnv(
|
||||
"RUNNER_HEARTBEAT_MS",
|
||||
env.RUNNER_HEARTBEAT_MS,
|
||||
1000,
|
||||
),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user