/** * Proxy reachability and failure attribution. * * Why this file exists: a dead proxy makes the automation browser show a Chrome * error page. That page has no ChatGPT markup, so an honest-looking "you are not * logged in" is easy to produce and completely wrong -- the user *is* logged in, * the network path is what broke. Misattribution is more expensive than no * diagnosis, so the proxy is probed explicitly and any page failure is * attributed with the proxy state already known. * * P0 scope, deliberately small: is the configured proxy reachable, and does that * change how a page failure is explained. No PAC parsing, no VPN discovery, no * system-proxy resolver -- BRB is not a network diagnostics product. */ import { connect } from "node:net"; export type ProxyState = "NOT_CONFIGURED" | "REACHABLE" | "UNREACHABLE" | "INVALID"; export interface ProxyProbeResult { state: ProxyState; /** Host:port that was probed, when the URL parsed. */ target?: string; /** Human-readable reason, suitable for doctor output. */ detail: string; } export interface ProxyProbeDeps { /** TCP connect test. Resolves true when the port accepts a connection. */ connect?: (host: string, port: number, timeoutMs: number) => Promise; timeoutMs?: number; } /** * Parse a proxy address into host + port. * * Accepts the scheme-less `host:port` form as well as `http://` / `socks5://`: the * launcher hands the raw string to the browser (`--proxy-server=127.0.0.1:7890`), * which Chrome and Edge accept, so requiring a scheme here made doctor report * "代理: INVALID" for an address the browser was happily using (BUG-047). */ export function parseProxy(proxy?: string): { host: string; port: number } | { error: string } { const raw = proxy?.trim(); if (!raw) return { error: "未配置代理" }; let url: URL; try { // `new URL("127.0.0.1:7890")` parses the address as the protocol, so the // scheme has to be supplied before parsing rather than after failing. const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; url = new URL(withScheme); } catch { return { error: `无法解析代理地址:${raw}` }; } const scheme = url.protocol.replace(":", "").toLowerCase(); const defaultPort = scheme === "https" ? 443 : scheme === "socks" || scheme === "socks5" ? 1080 : 8080; const port = url.port ? Number(url.port) : defaultPort; if (!url.hostname) return { error: `代理地址缺少主机名:${raw}` }; if (!Number.isFinite(port) || port <= 0 || port > 65535) return { error: `代理端口无效:${raw}` }; return { host: url.hostname, port }; } function defaultConnect(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve) => { const socket = connect({ host, port }); const done = (ok: boolean) => { socket.removeAllListeners(); socket.destroy(); resolve(ok); }; socket.setTimeout(timeoutMs); socket.once("connect", () => done(true)); socket.once("timeout", () => done(false)); socket.once("error", () => done(false)); }); } /** * Probe the configured proxy. * * "Unreachable" is a definite answer (the port refused or timed out); it is * never reported as "not configured", because those two facts lead the user to * completely different actions. */ export async function probeProxy(proxy?: string, deps: ProxyProbeDeps = {}): Promise { const parsed = parseProxy(proxy); const timeoutMs = deps.timeoutMs ?? 1500; if ("error" in parsed) { const notConfigured = parsed.error === "未配置代理"; return { state: notConfigured ? "NOT_CONFIGURED" : "INVALID", detail: `${parsed.error}。` }; } const target = `${parsed.host}:${parsed.port}`; const connectTest = deps.connect ?? defaultConnect; const reachable = await connectTest(parsed.host, parsed.port, timeoutMs); if (reachable) { return { state: "REACHABLE", target, detail: `代理 ${target} 可连接。` }; } return { state: "UNREACHABLE", target, detail: `代理 ${target} 不可连接;这会让自动化浏览器打开错误页,并可能看起来像"未登录"。`, }; } export type PageFailureReason = | "AUTH_REQUIRED" | "PAGE_NOT_READY" | "PAGE_UNREACHABLE" | "TARGET_MISMATCH" | "UNKNOWN"; export interface Attribution { /** The reason after the proxy state is taken into account. */ reason: PageFailureReason; /** True when the proxy is the more likely explanation than the page's own signal. */ proxySuspect: boolean; message: string; } /** * Attribute a page failure with the proxy state known. * * The single rule that matters: when the proxy is unreachable, a page that looks * like "not logged in" is not evidence of a login problem. It is reported as a * network-path problem, because that is what the user can actually fix. */ export function attributeFailure(pageReason: PageFailureReason, proxy: ProxyProbeResult): Attribution { // A dead proxy explains every page-level failure, including the deceptive // "logged in but not ready" state: with the proxy down the browser shows its // own error page, which has no ChatGPT markup at all. TARGET_MISMATCH is // excluded on purpose -- there the page loaded fine and the wrong conversation // is the real problem. const proxyExplainsPage = pageReason === "AUTH_REQUIRED" || pageReason === "PAGE_NOT_READY" || pageReason === "PAGE_UNREACHABLE"; if (proxy.state === "UNREACHABLE" && proxyExplainsPage) { return { reason: pageReason, proxySuspect: true, message: `自动化浏览器无法访问 ChatGPT:代理 ${proxy.target ?? "(已配置)"} 不可连接。` + `这通常表现为错误页或"未登录",请先启动代理再重试。`, }; } if (proxy.state === "INVALID" && pageReason === "PAGE_UNREACHABLE") { return { reason: pageReason, proxySuspect: true, message: `代理配置无效,且页面不可达:${proxy.detail}` }; } switch (pageReason) { case "AUTH_REQUIRED": return { reason: pageReason, proxySuspect: false, message: "自动化浏览器已打开 ChatGPT,但需要登录。请在 BRB 专用窗口中手动登录。", }; case "PAGE_NOT_READY": return { reason: pageReason, proxySuspect: false, message: "ChatGPT 已登录,但页面还没有准备好。请等待页面加载完成后重试。", }; case "PAGE_UNREACHABLE": return { reason: pageReason, proxySuspect: false, message: "无法打开 ChatGPT 页面。" }; case "TARGET_MISMATCH": return { reason: pageReason, proxySuspect: false, message: "当前页面不是预期的 ChatGPT 会话。" }; default: return { reason: "UNKNOWN", proxySuspect: false, message: "页面状态无法确认。" }; } }