/** * Startup self-check: does the URL we tell users to open actually reach US? * Issue #2113 * * CommandMate binds IPv4 `127.0.0.1` by default (server.ts, `CM_BIND`), but every * setup guide sends the user to `http://localhost:`. On macOS `localhost` * resolves `::1` (IPv6) BEFORE `127.0.0.1`, and a `0.0.0.0`/`::` bind of ours never * covers `::1` either — so an unrelated process holding `::1:` silently owns * the address the docs advertise. The browser then talks to that process while * CommandMate keeps reporting a healthy `> Ready on http://127.0.0.1:`. * * Measured 2026-08-27 on develop 54e122a9: `127.0.0.1:3000` answered in 14ms while * `localhost:3000` and `[::1]:3000` both timed out after 10s against a stray Next.js * dev server. Because the squatter was also Next.js, the browser pulled chunks from * the WRONG app and rendered CommandMate's own `error.chunkReload.title` * ("Updating to the latest version") — an error screen that looks like ours while * not being served by us at all. * * HOW IDENTITY IS DECIDED (the part that has to be exact): * * The probe sends a GET to `://localhost:/api/auth/status` carrying a * one-shot random nonce in `x-cm-self-check`, and our own HTTP server carries a * temporary `prependListener('request')` that watches for that nonce. The verdict comes * from OUR OWN OBSERVATION of the request, never from trusting the response body: * * observed the nonce -> 'self' (the advertised URL reaches us) * got an HTTP response, no nonce-> 'foreign' (something else answered) <- WARN * connection error / timeout -> 'unreachable' (nothing there; browsers fall * through to 127.0.0.1) <- silent * * `/api/auth/status` is deliberately an EXISTING endpoint (it is in * AUTH_EXCLUDED_PATHS, touches no DB and mutates nothing), so no diagnostic route had * to be added. Nothing in the verdict depends on what it returns, which is why the * check still works with auth on, with an IP ACL that would 403 us, and against an * HTTPS server with a self-signed certificate. * * FAIL-OPEN IS THE CONTRACT. Nothing here throws, nothing here blocks `listen`, and * only the 'foreign' verdict produces output. 'unreachable' is deliberately silent: * an empty `::1` is the NORMAL case on a machine where nothing squats the port, and * Node's Happy Eyeballs (and every browser) then falls through to `127.0.0.1`. * * @module lib/server/localhost-self-check */ import { type IncomingMessage, type ServerResponse } from 'http'; /** Header the probe request carries its nonce in */ export declare const SELF_CHECK_HEADER = "x-cm-self-check"; /** * Endpoint the probe targets. Existing route, in AUTH_EXCLUDED_PATHS * (src/config/auth-config.ts), no DB access, no side effects. */ export declare const SELF_CHECK_PATH = "/api/auth/status"; /** Hostname the docs advertise, and therefore the one under test */ export declare const SELF_CHECK_HOSTNAME = "localhost"; /** How long to wait for either the nonce observation or a response */ export declare const SELF_CHECK_TIMEOUT_MS = 3000; /** * Directory (under the config dir) the conflict records live in. * * `logs/` and not a dedicated `self-check/`: in a local install `getConfigDir()` is the * repository checkout itself, and `.gitignore` already ignores `logs/` there (it does * the same for `data/`, `skills/`, `temp/` — "service-owned runtime state lands at the * repo root"). A new top-level directory would show up as an untracked change in the * user's own repository, which for a worktree manager means dirtying a repo it also * displays. The record is diagnostic output keyed by port, so `logs/` fits it anyway. */ export declare const SELF_CHECK_DIR_NAME = "logs"; /** Filename prefix of a conflict record inside {@link SELF_CHECK_DIR_NAME} */ export declare const SELF_CHECK_FILE_PREFIX = "self-check-"; /** * What the startup probe concluded. * * Only `foreign` is actionable; see the module header for why `unreachable` is silent. */ export type LocalhostProbeVerdict = 'self' | 'foreign' | 'unreachable'; /** * The record a conflicted server leaves behind for `commandmate status`. * * `pid` is what makes the record trustworthy: `status` only reports it when it matches * the PID it just read out of the state file, so a record left by an earlier server on * the same port can never be attributed to the current one. */ export interface LocalhostConflictRecord { /** Port the conflict was observed on */ port: number; /** PID of the CommandMate server that observed it */ pid: number; /** CM_BIND as configured */ bind: string; /** URL CommandMate actually listens on */ boundUrl: string; /** URL that was probed, i.e. the one the documentation advertises */ probedUrl: string; /** ISO timestamp of the observation */ detectedAt: string; } /** * The slice of `http.Server` the probe needs. Structural on purpose: tests hand it a * real `http.Server`, and nothing here should be able to touch the rest of the server. */ export interface RequestObserver { prependListener(event: 'request', listener: (req: IncomingMessage, res: ServerResponse) => void): unknown; removeListener(event: 'request', listener: (req: IncomingMessage, res: ServerResponse) => void): unknown; } /** Options for {@link probeLocalhostIdentity} */ export interface ProbeLocalhostOptions { /** Our own HTTP(S) server, used to observe the probe request landing on us */ server: RequestObserver; /** Port the server listens on */ port: number; /** Protocol the server speaks (default: 'http') */ protocol?: 'http' | 'https'; /** Hostname to probe (default: {@link SELF_CHECK_HOSTNAME}); overridden in tests */ host?: string; /** Request path (default: {@link SELF_CHECK_PATH}) */ path?: string; /** Deadline for the whole probe (default: {@link SELF_CHECK_TIMEOUT_MS}) */ timeoutMs?: number; /** Nonce override; generated per call when omitted */ nonce?: string; } /** * Ask "if a user opens the URL we advertise, do they reach this process?". * * Resolves as soon as the answer is known — the nonce observation fires on our * server's `request` event, i.e. when headers arrive, so a 'self' verdict does not * wait for Next.js to render or compile the route. * * Never rejects. */ export declare function probeLocalhostIdentity(options: ProbeLocalhostOptions): Promise; /** * Build the dialable URL for a bind address, matching resolveServerEndpoint()'s rule * that a wildcard bind is reported as 127.0.0.1. */ export declare function formatBoundUrl(protocol: 'http' | 'https', bind: string, port: number): string; /** * The warning, as lines, shared by the startup log and `commandmate status` so the two * can never drift apart. */ export declare function formatLocalhostConflictWarning(record: LocalhostConflictRecord): string[]; /** Directory holding the per-port conflict records */ export declare function getSelfCheckDir(): string; /** * Path of the record for one port (`/logs/self-check-.json`). * * @throws Error when the port is not a plausible TCP port — the value becomes a * filename, so it is validated rather than interpolated blindly. */ export declare function getSelfCheckStatePath(port: number): string; /** * Persist a conflict for `commandmate status` to read. * * @returns true when the record was written */ export declare function writeLocalhostConflict(record: LocalhostConflictRecord): boolean; /** * Read the conflict recorded for a port. * * Never throws: `status` must keep working on an unreadable, truncated or * hand-edited record, and on a machine where the config dir cannot be resolved at all. * * @returns The record, or null when there is none (or it is unusable) */ export declare function readLocalhostConflict(port: number): LocalhostConflictRecord | null; /** * Drop the record for a port. Called on every clean self-check and on shutdown, so a * fixed environment stops warning on the next start rather than needing a manual sweep. */ export declare function clearLocalhostConflict(port: number): void; /** Options for {@link runLocalhostSelfCheck} */ export interface RunLocalhostSelfCheckOptions { /** Our own HTTP(S) server */ server: RequestObserver; /** Port the server listens on */ port: number; /** CM_BIND as configured */ bind: string; /** Protocol the server speaks (default: 'http') */ protocol?: 'http' | 'https'; /** Hostname to probe (default: {@link SELF_CHECK_HOSTNAME}); overridden in tests */ host?: string; /** Deadline for the probe */ timeoutMs?: number; /** PID to stamp the record with (default: `process.pid`) */ pid?: number; /** Clock, injectable for deterministic tests */ now?: () => Date; /** Where the warning goes (default: `console.warn`) */ warn?: (message: string) => void; /** Probe override, injected by tests */ probe?: (options: ProbeLocalhostOptions) => Promise; } /** * Run the startup self-check and route its verdict to the log and the status file. * * Fail-open by construction: every path is wrapped, the function never rejects, and the * caller in server.ts does not await it. A `null` return means the check could not be * carried out at all — which is reported as nothing, exactly like 'unreachable'. * * @returns The verdict, or null when the check itself failed */ export declare function runLocalhostSelfCheck(options: RunLocalhostSelfCheckOptions): Promise; //# sourceMappingURL=localhost-self-check.d.ts.map