/** * Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9): * - relayUrl MUST be wss:// (encrypted tunnel only) * - enrollUrl MUST be https:// (enrollment over TLS only) * - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to * the local web-terminal, never an arbitrary target). * Fail-fast on missing/invalid values — never silently default a security-relevant field. */ import { homedir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js' export interface AgentConfig { readonly relayUrl: string readonly enrollUrl: string readonly stateDir: string readonly localTargetUrl: string readonly subdomain: string | null readonly hostId: string | null } /** * True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or * ::1). Uses the shared strict check so a crafted suffixed hostname such as * `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect * to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`). */ export function isLoopbackWsUrl(url: string): boolean { let parsed: URL try { parsed = new URL(url) } catch { return false } if (parsed.protocol !== 'ws:') return false return isLoopbackHostLiteral(parsed.hostname) } function hasScheme(url: string, scheme: string): boolean { try { return new URL(url).protocol === scheme } catch { return false } } export const AgentConfigSchema = z .object({ relayUrl: z .string() .refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'), enrollUrl: z .string() .refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'), stateDir: z.string().min(1), localTargetUrl: z .string() .refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'), subdomain: z.string().min(1).nullable(), hostId: z.string().min(1).nullable(), }) .strict() .readonly() const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000' /** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */ export function defaultStateDir(): string { return join(homedir(), '.web-terminal-agent') } /** * Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a * ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement. */ export function loadAgentConfig( env: NodeJS.ProcessEnv, argv: Partial = {}, ): AgentConfig { const merged = { relayUrl: argv.relayUrl ?? env.RELAY_URL, enrollUrl: argv.enrollUrl ?? env.ENROLL_URL, stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(), localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET, subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null, hostId: argv.hostId ?? env.HOST_ID ?? null, } return AgentConfigSchema.parse(merged) }