Files
agentic-dev/apps/runner/src/config.ts
Danijel Martinek b090e26701 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
2026-07-12 22:50:41 +02:00

67 lines
2.1 KiB
TypeScript

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