/** * Which browser this harness should drive. * * Upstream only ever drives a browser on localhost. This fork adds a remote * target so pi can drive a shared Chrome on another host (see FORK.md). * * Resolution is pure — no I/O — so the status command can report the configured * intent without touching the network, and the daemon can resolve the same way * in its own process. */ /** Default remote CDP endpoint: castle's Chrome, exposed on the wire network. */ export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223"; /** Values of BU_CDP_HTTP that mean "ignore the default, use this machine". */ const LOCAL_ALIASES = new Set(["local", "localhost", "off", "none", "0", "no"]); /** * Where a target came from. "ws" is upstream's BU_CDP_WS, "env" is BU_CDP_HTTP, * "stored" is /browser-target, "default" is this fork's built-in. Only * "default" is allowed to fall back. */ export type CdpTargetSource = "ws" | "env" | "stored" | "default"; export type CdpTarget = | { readonly kind: "remote"; readonly host: string; readonly port: number; readonly source: CdpTargetSource; } | { readonly kind: "local"; readonly source: Exclude } | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource }; /** * Precedence: BU_CDP_WS, then BU_CDP_HTTP, then the /browser-target choice, * then the built-in default. * * BU_CDP_WS -> upstream's pinned WebSocket URL; discoverEndpoint() returns * it before any of the rest is consulted, so it is reported * here only so status, /browser-target and the prompt agree * with what the daemon actually dials * * "host:port" -> that remote, and a failure to reach it is fatal * local|off|… -> upstream behaviour, this machine's own browser * neither set -> DEFAULT_REMOTE_CDP, falling back to local when unreachable * * The environment wins over the stored choice so that a one-off * `BU_CDP_HTTP=… pi` is never silently overridden by a setting made days ago. * * The fallback asymmetry is deliberate. An explicit endpoint — from either * source — is an instruction, so silently driving a different browser instead * would be wrong: it would automate the operator's own signed-in Chrome rather * than the shared one. The built-in default is only a preference, so a laptop * away from the wire network still gets the local browser the extension was * originally written for. * * `stored` is passed in rather than read here so this stays pure and * synchronous: /browser-status can describe the target without I/O, and the * daemon can resolve identically in its own process. */ export const resolveCdpTarget = ( env: NodeJS.ProcessEnv = process.env, stored: string | null = null, ): CdpTarget => { // FORK: mirror upstream's endpointFromEnv() in discovery.ts, which wins. const pinnedWs = env["BU_CDP_WS"]?.trim(); if (pinnedWs) { const parsed = parseWsUrl(pinnedWs); if (!parsed) { return { kind: "invalid", raw: redactWsUrl(pinnedWs), source: "ws", reason: "expected a ws:// or wss:// URL" }; } return { kind: "remote", host: parsed.host, port: parsed.port, source: "ws" }; } const fromEnv = env["BU_CDP_HTTP"]?.trim(); const fromStore = stored?.trim(); const raw = fromEnv !== undefined && fromEnv !== "" ? fromEnv : fromStore; const source: CdpTargetSource = fromEnv !== undefined && fromEnv !== "" ? "env" : "stored"; if (raw === undefined || raw === "") { const parsed = parseHostPort(DEFAULT_REMOTE_CDP); // Unreachable in practice: the constant above is validated by its own test. if (!parsed) return { kind: "local", source: "env" }; return { kind: "remote", host: parsed.host, port: parsed.port, source: "default" }; } if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source }; const parsed = parseHostPort(raw); if (!parsed) { return { kind: "invalid", raw, source, reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`, }; } return { kind: "remote", host: parsed.host, port: parsed.port, source }; }; /** The vocabulary /browser-target accepts, for help text and validation. */ export const localAliases = (): ReadonlyArray => [...LOCAL_ALIASES]; /** True when `value` is something resolveCdpTarget can make sense of. */ export const isValidTargetValue = (value: string): boolean => { const v = value.trim(); if (v === "") return false; return LOCAL_ALIASES.has(v.toLowerCase()) || parseHostPort(v) !== null; }; const parseHostPort = (value: string): { host: string; port: number } | null => { const sep = value.lastIndexOf(":"); if (sep === -1) return null; const host = value.slice(0, sep).trim(); const port = Number(value.slice(sep + 1).trim()); if (!host) return null; if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; return { host, port }; }; /** * host:port of a BU_CDP_WS URL. Only that much is ever shown: the path carries * the browser's id, and a proxied endpoint may carry a token or credentials. */ const parseWsUrl = (value: string): { host: string; port: number } | null => { let url: URL; try { url = new URL(value); } catch { return null; } if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; const host = url.hostname.replace(/^\[(.*)\]$/, "$1"); if (!host) return null; const port = url.port === "" ? (url.protocol === "wss:" ? 443 : 80) : Number(url.port); return { host, port }; }; /** A BU_CDP_WS value safe to print: host:port when parseable, otherwise a placeholder. */ export const redactWsUrl = (value: string): string => { const parsed = parseWsUrl(value.trim()); return parsed ? `${parsed.host}:${parsed.port}` : ""; }; const sourceLabel = (source: CdpTargetSource): string => source === "ws" ? "BU_CDP_WS, pinned WebSocket" : source === "env" ? "BU_CDP_HTTP" : source === "stored" ? "/browser-target" : "built-in default"; /** One-line description for /browser-status and error messages. */ export const describeCdpTarget = (target: CdpTarget): string => { switch (target.kind) { case "remote": return `${target.host}:${target.port} (${sourceLabel(target.source)})`; case "local": return `this machine's own browser (${sourceLabel(target.source)})`; case "invalid": return `invalid target "${target.raw}" from ${sourceLabel(target.source)} — ${target.reason}`; } };