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 `/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; 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, ), }); }