fix(console): 让「检查更新」在境内真的能用 —— 自持出网 + 系统代理发现

真机症状:控制台点「检查更新」必报 `检查更新失败:fetch failed`。
上一轮曾把此项归因为「环境网络问题」而收工;本轮按「即使环境本身不通,
代码也不该让用户无从自救」重新定位,确认为**产品缺陷**。

三条根因(均经隔离实测)
- Node 内建 fetch(undici)默认不读任何代理环境变量。`NODE_USE_ENV_PROXY=1` 可开,
  未开时 env 被**完全忽略** —— `env -i` 对照实验:带 flag 得 `fetch failed`(证明 flag 生效),
  不带得 `timeout`(证明 env 被忽略)。
- 系统代理(WinINET / macOS 网络设置)Node 亦不读;而 `fetch(url,{dispatcher})` 走不通 ——
  dispatcher 必须是 undici 的 Dispatcher 实例,Node 未把 undici 暴露为可导入模块,
  整包 vendored 会撑胖 sidecar 且其依赖 .wasm 解析器,esbuild 单文件打包有风险。
- 内部超时 20s 对真实重定向链必然失败:`latest.json` → 版本化 URL → CDN,
  实测耗时 2.0 / 10.0 / 39.7 / 68.6 / >109 秒。

修法
- 新增 `apps/console/sidecar/proxy.mjs`(自持出网,零新依赖):https 目标走 `CONNECT host:443`
  隧道后叠 TLS(隧道返回的 socket 已是 TLS,须用 `http.request` 承载,用 `https.request` 会二次握手;
  且**不可把 URL 对象当第一参**,否则会覆盖手搭的代理字段退化成直连);http 目标走绝对形态请求行;
  含 NO_PROXY 匹配、系统代理发现(`reg query` / `scutil --proxy`,PAC 不可解析时记 note 而非静默)、
  socks5 等看不懂的形态一律记 note。
- `check_update` / `download` 全部改走 `httpGet`,整条重定向链共享一个超时预算与 AbortController。
- 内外超时成对下发:`sidecar.rs` 新增 `UPDATE_CHECK_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 与
  `UPDATE_DOWNLOAD_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 两组常量 + **编译期不变式**
  (`const _: () => assert!(...)`),保证「出网超时 < RPC 超时」——否则用户只会看到
  丢掉真实原因的「check_update 超时」假失败。订正 6 处因超时改动而变陈旧的注释。
- 失败信息带 `url` + `via`/`proxy`/`proxy_source`/`timeout_ms`/`hint`;界面新增「出网」行,
  并修掉重复前缀(`检查更新失败:检查更新失败:…`)。

顺带修掉两个真机暴露的缺陷
- **隧道/TLS 阶段的 abort 无人受理**:`createConnection` 回调返回前外层请求尚未建立,
  外层 `signal`/`timeout` 无人受理 → 真机实测配 75s 超时却挂了 109s。
- **`ECONNRESET` 被归入 abort 类** → 报成「请求超时」(真机:耗时 82.9s 却报「120000ms 内未完成」)。
  改为只认真 abort,并新增网络错误翻译(ENOTFOUND/EAI_AGAIN/ECONNREFUSED/ECONNRESET/EPIPE)。

守卫与验证
- 新增 `tests/unit/sidecar-proxy.test.mjs`(16 用例,四段:纯函数 / 真发请求 / 端到端
  spawn sidecar 跑 JSON-RPC / 形态守卫)。夹具用本机真 `scutil --proxy` 与真 `reg query` 输出,
  地址改为 RFC 5737 文档段,不写内网 IP、不写工具实名。
- 守卫落在「最终下发的请求」与「产物字节」上,逐条**注入故障实测会红、还原后逐字节一致**:
  ① `check_update` 退回裸 `fetch` → 代理未收到该次请求 → 红
  ② 去掉 `isBypassed` 后缀匹配 → 红
  ③ 出网/RPC 超时改回反序 → 红 / `error[E0080]`
  ④ 去掉隧道 abort 挂载 → 红(`实际 4002ms`)
  ⑤ `ECONNRESET` 塞回 abort 类 → 红
- 全量:`unit` **341/341**、`rust` **18/18**、`clippy -D warnings` 干净、`vue-tsc` 退出码 0。

未覆盖项(如实记档)
- https 经 CONNECT 隧道叠 TLS 无离线自动化用例:`selfsigned` 只在 `apps/agent` 包内,
  测试进程解析不到 → 无法离线造受信任 TLS 站点;该路径仅真机验证。
- 真机「经代理取 GitHub 成功」的那一组未取到(宿主两条代理线路当时都取不到 GitHub),
  属环境而非产品缺陷;错误信息已可操作(含 `via`/`proxy_source`/`hint`)。
- 同类缺陷**第三处未改**:`apps/agent/src/capabilities/selfupdate.ts` 仍是裸 `fetch`
  (属被控端变更,改则需升 PATCH)。

注:本项为上壳变更,不触发 release patch;`apps/console` 至今未接入 `release.mjs`。
This commit is contained in:
jkooe committed 2026-10-13 22:41:02 +08:00
1 parent b9236dfaf1
commit b34e8c73e0
7 files changed
+1347 -35

No files matched your search

+109 -24
View File
@@ -13,8 +13,11 @@
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
* {"jsonrpc":"2.0","id":4,"method":"state"}
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?}}
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}}
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?,timeout_ms?}}
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest,timeout_ms?}}
* ⚠️ `timeout_ms` 由 Rust 侧下发,**必须小于**它在 `sidecar.rs::request` 上的 RPC 超时,
* 否则永远看到的是「RPC 超时」这种假失败(拿不到真正的原因)。两侧口径在
* apps/console/src-tauri/src/lib.rs 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_RPC_TIMEOUT_SECS` 里成对定义。
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
* 通知(无 id,本进程主动推):
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
@@ -31,6 +34,7 @@ import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { NodeAgentClient } from '@nodeagent/client';
import { httpGet, resolveProxy } from './proxy.mjs';
/** @type {NodeAgentClient | null} */
let client = null;
@@ -94,6 +98,72 @@ function cmpVersion(a, b) {
return 0;
}
/**
* 出网超时(毫秒)的裁剪。参数由 Rust 侧下发,**默认值只作兜底** —— 真实口径以
* `lib.rs` 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_DOWNLOAD_TIMEOUT_MS` 为准。
*/
function clampTimeoutMs(raw, dflt, min = 5_000, max = 1_800_000) {
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : dflt;
return Math.max(min, Math.min(max, n));
}
/**
* 决议代理是否可用;**任何异常都退化成「直连」**。
*
* 代理只是为了「本来连不上时能连上」,它本身绝不该成为新的失败点:
* 探测系统代理要跑 `reg`/`scutil`,被安全软件拦、被超时都是可能的,
* 那种情况下退回直连(与改动前行为一致)比让「检查更新」直接报错要好。
*/
async function resolveProxyOrDirect(targetUrl) {
try {
return await resolveProxy({ targetUrl });
} catch (err) {
return { proxy: null, source: null, notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`] };
}
}
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
function outboundHint({ proxy, source, notes, timeoutMs }) {
return {
via: proxy ? `经代理 ${proxy.href}` : '直连',
proxy: proxy ? proxy.href : null,
proxy_source: source,
timeout_ms: timeoutMs,
notes,
hint: proxy
? '代理已生效但仍失败:请确认代理本身能访问 GitHub(浏览器里试同一个地址)'
: '未使用代理。若本机需要代理才能访问 GitHub,请在系统里开启「系统代理」,或设置环境变量 HTTPS_PROXY=http://127.0.0.1:<端口> 后重启控制台',
};
}
/**
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
*
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(第一版就这么写的,真机上立刻现形):
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
*/
function isAbortLike(err) {
const name = err?.name ?? '';
const code = err?.code ?? '';
const msg = String(err?.message ?? '');
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
}
/** 把底层网络错误翻译成「用户能据以行动」的一句话。 */
function describeNetError(err, timeoutMs) {
const msg = err instanceof Error ? err.message : String(err);
const code = err?.code ?? '';
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
if (code === 'ECONNRESET' || code === 'EPIPE') {
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
}
return msg;
}
const methods = {
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
async connect(params) {
@@ -189,26 +259,30 @@ const methods = {
*/
async check_update(params) {
const consoleVersion = String(params?.console_version ?? '0.0.0');
const url =
params?.url ??
'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json';
const url = String(
params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json',
);
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 20_000);
const out = await resolveProxyOrDirect(url);
log('info', `检查更新:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
for (const n of out.notes) log('warn', `检查更新:${n}`);
const t0 = Date.now();
let meta;
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
if (res.status < 200 || res.status >= 300) {
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
}
meta = await res.json();
meta = JSON.parse(res.body.toString('utf8'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), {
data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' },
});
} finally {
clearTimeout(timer);
const msg = describeNetError(err, timeoutMs);
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
throw Object.assign(
new Error(`检查更新失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`),
{ data: { url, ...outboundHint({ ...out, timeoutMs }) } },
);
}
const latest = String(meta?.version ?? '');
@@ -230,6 +304,9 @@ const methods = {
},
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
source: url,
// 让界面/日志能一眼看出这次是直连还是走了代理(真机复验与用户自查都需要)
via_proxy: out.source,
via: out.proxy ? `经代理 ${out.proxy.href}` : '直连',
checked_at: Date.now(),
};
},
@@ -252,17 +329,25 @@ const methods = {
);
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 300_000);
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 300_000);
const out = await resolveProxyOrDirect(url);
log('info', `下载更新包:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
const t0 = Date.now();
let buf;
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
buf = Buffer.from(await res.arrayBuffer());
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
if (res.status < 200 || res.status >= 300) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
buf = res.body;
} catch (err) {
throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
clearTimeout(timer);
const msg = describeNetError(err, timeoutMs);
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
throw Object.assign(new Error(`下载失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), {
data: { url, ...outboundHint({ ...out, timeoutMs }) },
});
}
const got = createHash('sha256').update(buf).digest('hex');
+523
View File
@@ -0,0 +1,523 @@
/**
* 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。
*
* ## 为什么必须自己实现(这是缺陷,不是优化)
*
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。
* 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。
* (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器
* 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。)
*
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar
* 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
*
* ## 做法
*
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
* - **无代理**:与改动前一致,直连(走 `https.request`)。
*
* ## 代理来源优先级
*
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
*
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
*
* ## 安全
*
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { execFile } from 'node:child_process';
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
const ENV_ORDER = {
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
};
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const UA = 'nodeagent-console-sidecar';
/**
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
*
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
* 明确报出来,**不许静默直连**。
*
* @param {unknown} raw
* @returns {URL | null}
*/
export function normalizeProxyUrl(raw) {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s) return null;
let u;
try {
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
return u;
}
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
function proxyEndpoint(u) {
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
}
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
function proxyAuthHeader(u) {
if (!u.username) return null;
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
return 'Basic ' + Buffer.from(raw).toString('base64');
}
/**
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
*
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
*
* @param {string} targetUrl
* @param {string} noProxy
* @returns {boolean}
*/
export function isBypassed(targetUrl, noProxy) {
if (!noProxy) return false;
let host;
let port;
try {
const u = new URL(targetUrl);
host = u.hostname.toLowerCase();
port = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
for (const rawEntry of String(noProxy).split(',')) {
const entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === '*') return true;
let eHost = entry;
let ePort = null;
const i = entry.lastIndexOf(':');
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
if (i > 0 && !entry.slice(0, i).includes(':')) {
eHost = entry.slice(0, i);
ePort = entry.slice(i + 1);
}
if (ePort && ePort !== port) continue;
const bare = eHost.replace(/^\./, '');
if (!bare) continue;
if (host === bare || host.endsWith(`.${bare}`)) return true;
}
return false;
}
/**
* 从环境变量里挑代理(纯函数,便于单测)。
*
* @param {Record<string, string | undefined>} env
* @param {string} targetUrl
* @returns {{ url: URL | null, source: string | null, notes: string[] }}
*/
export function pickEnvProxy(env, targetUrl) {
let proto = 'https:';
try {
proto = new URL(targetUrl).protocol;
} catch {
/* 保持默认 */
}
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:'];
const notes = [];
for (const k of keys) {
const v = env?.[k];
if (!v) continue;
const u = normalizeProxyUrl(v);
if (u) return { url: u, source: k, notes };
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
}
return { url: null, source: null, notes };
}
/**
* 解析 macOS `scutil --proxy` 的输出。
*
* 形如:
* <dictionary> {
* HTTPSEnable : 1
* HTTPSPort : 7890
* HTTPSProxy : 127.0.0.1
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
* }
*
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseScutilProxy(text) {
if (typeof text !== 'string' || !text.includes('{')) return null;
const scalar = (key) => {
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const isOn = (v) => v === '1' || v === 'true';
const notes = [];
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
const bypass = [];
const arrIdx = text.indexOf('ExceptionsList');
if (arrIdx >= 0) {
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
let m;
while ((m = re.exec(body)) !== null) bypass.push(m[1].trim());
}
for (const [flag, hostKey, portKey, dflt] of [
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
]) {
if (!isOn(scalar(flag))) continue;
const host = scalar(hostKey);
if (!host) continue;
const port = scalar(portKey) ?? dflt;
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
}
if (isOn(scalar('ProxyAutoConfigEnable'))) {
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
}
return { url: null, bypass: bypass.join(','), notes };
}
/**
* 解析 Windows `reg query …\Internet Settings` 的输出。
*
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
* ProxyEnable REG_DWORD 0x1
* ProxyOverride REG_SZ *.local;<local>
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
*
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseRegQueryProxy(text) {
if (typeof text !== 'string' || !text.includes('REG_')) return null;
const value = (name) => {
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const notes = [];
const enable = value('ProxyEnable');
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
const server = value('ProxyServer');
if (!server) return { url: null, bypass: '', notes };
const override = value('ProxyOverride');
const bypass = override
? override
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^<local>$/i.test(s))
.join(',')
: '';
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
let url = server;
if (server.includes('=')) {
const map = new Map();
for (const part of server.split(';')) {
const i = part.indexOf('=');
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
url = map.get('https') ?? map.get('http') ?? null;
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
}
return { url, bypass, notes };
}
/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */
function runQuiet(file, args) {
return new Promise((resolve) => {
execFile(
file,
args,
// windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程,
// 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
(err, stdout) => resolve(err ? null : String(stdout)),
);
});
}
/**
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
*
* @param {NodeJS.Platform} [platform]
* @param {(file: string, args: string[]) => Promise<string | null>} [run]
* @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>}
*/
export async function detectSystemProxy(platform = process.platform, run = runQuiet) {
try {
if (platform === 'win32') {
const out = await run('reg', [
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
]);
return out ? parseRegQueryProxy(out) : null;
}
if (platform === 'darwin') {
const out = await run('scutil', ['--proxy']);
return out ? parseScutilProxy(out) : null;
}
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
return null;
} catch {
return null;
}
}
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */
let systemProxyPromise;
export function systemProxy() {
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
return systemProxyPromise;
}
/** 单测用:清掉系统代理缓存。 */
export function __resetSystemProxyCache() {
systemProxyPromise = undefined;
}
/**
* 决议本次请求该用哪个代理。
*
* @param {{ targetUrl: string, env?: Record<string, string | undefined>, system?: unknown }} opts
* `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
* @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>}
*/
export async function resolveProxy({ targetUrl, env = process.env, system } = {}) {
const notes = [];
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
if (noProxy && isBypassed(targetUrl, noProxy)) {
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
}
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
notes.push(...fromEnv.notes);
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
const sys = system === undefined ? await systemProxy() : system;
if (sys?.url) {
notes.push(...(sys.notes ?? []));
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
return { proxy: null, source: null, notes };
}
const u = normalizeProxyUrl(String(sys.url));
if (u) return { proxy: u, source: 'system', notes };
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
return { proxy: null, source: null, notes };
}
notes.push(...(sys?.notes ?? []));
return { proxy: null, source: null, notes };
}
/**
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
*
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
*
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
*/
export function tunnelConnection(proxy, target, signal) {
const pe = proxyEndpoint(proxy);
return (_opts, cb) => {
let settled = false;
let tunnelSock = null;
let tlsSock = null;
const detach = () => {
if (signal) signal.removeEventListener('abort', onAbort);
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
};
const fail = (err) => {
if (settled) return;
settled = true;
detach();
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆)
try {
tlsSock?.destroy();
tunnelSock?.destroy();
} catch {
/* 已经没了 */
}
cb(err);
};
const onSockError = (e) => fail(e);
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
const port = target.port || (target.protocol === 'https:' ? 443 : 80);
const headers = { host: `${target.hostname}:${port}` };
const auth = proxyAuthHeader(proxy);
if (auth) headers['proxy-authorization'] = auth;
const req = http.request({
host: pe.host,
port: pe.port,
method: 'CONNECT',
path: `${target.hostname}:${port}`,
headers,
agent: false,
signal,
});
req.once('connect', (res, socket, head) => {
tunnelSock = socket;
if (res.statusCode !== 200) {
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
return;
}
if (head && head.length) socket.unshift(head);
if (target.protocol !== 'https:') {
settled = true;
detach();
cb(null, socket);
return;
}
socket.once('error', onSockError);
tlsSock = tls.connect({ socket, servername: target.hostname });
tlsSock.once('secureConnect', () => {
if (settled) return;
settled = true;
detach();
cb(null, tlsSock);
});
tlsSock.once('error', onSockError);
});
req.once('error', onSockError);
req.end();
};
}
/** 单跳请求(不跟随重定向)。 */
function once(urlStr, { proxy, timeoutMs, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const isHttps = u.protocol === 'https:';
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
reject(new Error(`不支持的协议:${u.protocol}`));
return;
}
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
const opts = {
method: 'GET',
signal,
timeout: timeoutMs,
host: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: `${u.pathname}${u.search}`,
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
};
let req;
if (proxy && !isHttps) {
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
const pe = proxyEndpoint(proxy);
opts.host = pe.host;
opts.port = pe.port;
opts.path = u.href;
const auth = proxyAuthHeader(proxy);
if (auth) opts.headers['proxy-authorization'] = auth;
req = http.request(opts);
} else if (proxy) {
opts.createConnection = tunnelConnection(proxy, u, signal);
req = http.request(opts);
} else {
req = (isHttps ? https : http).request(opts);
}
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
req.once('response', (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.once('error', reject);
res.once('end', () =>
resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }),
);
});
req.once('error', reject);
req.end();
});
}
/**
* GET 一个 URL(跟随重定向)。
*
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
*
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
*
* @param {string} url
* @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts]
* @returns {Promise<{ status: number, statusText: string, headers: Record<string, unknown>, body: Buffer, url: string }>}
*/
export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let current = String(url);
for (let hop = 0; ; hop += 1) {
const res = await once(current, { proxy, timeoutMs, signal: ac.signal });
const loc = res.headers?.location;
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
current = new URL(loc, current).href;
continue;
}
return { ...res, url: current };
}
} finally {
clearTimeout(timer);
}
}
+10 -3
View File
@@ -362,11 +362,17 @@ fn autostart_set(enabled: bool) -> Result<Value, String> {
#[tauri::command]
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
let sc = ensure_sidecar(&app, &state).await?;
// 超时**成对**下发:内层(sidecar 出网)必须小于外层(RPC),
// 否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败。
// 见 sidecar.rs 的 UPDATE_CHECK_TIMEOUT_MS / UPDATE_CHECK_RPC_TIMEOUT_SECS。
let mut snap = sc
.request(
"check_update",
json!({ "console_version": state.app_version }),
45,
json!({
"console_version": state.app_version,
"timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS,
}),
sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS,
)
.await?;
@@ -410,8 +416,9 @@ async fn apply_update(
"url": url,
"sha256": sha256,
"dest": staged.to_string_lossy(),
"timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS,
}),
320,
sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS,
)
.await?;
+68 -6
View File
@@ -25,6 +25,68 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::oneshot;
// ---------------------------------------------------------------------------
// 更新链路的超时口径(「检查更新 / 下载更新包」)
// ---------------------------------------------------------------------------
//
// 两层超时必须**成对**看:内层是 sidecar 自己的出网超时(下发给它),
// 外层是本壳等 RPC 的超时。**内层必须小于外层**,否则本壳先放弃,
// 用户只会看到「check_update 超时」——一条**丢掉真实原因**的假失败
// (2026-10-13 真机排查就被这种假失败带偏过一轮)。
//
// 下面那条单测断言的就是这层关系:改一侧忘了另一侧,测试直接红。
/// 「检查更新」下发给 sidecar 的**出网超时**(毫秒)。
///
/// ## 为什么从 20s 抬到 120s(2026-10-13 实测)
/// 取 `latest.json` 要**跟随两次重定向**(`releases/latest/download/…` → 版本化 URL →
/// 资产 CDN)。整条链的耗时波动极大,真机连续多次实测:
/// **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同一台机器、同一个代理)。
/// 原来的 20s 是「假定能直连 GitHub」时定的值 —— 对真实链路**必然**超时,
/// 于是用户看到的失败原因永远是「超时」,而不是「到底为什么连不上」。
///
/// 为什么敢给到 2 分钟:这是**用户手动点**的动作,界面上按钮有 loading 态
/// (`LocalServiceView.vue` 的 `:loading="busy"`),等待是可见的;
/// 而「假失败」会让用户以为功能坏了。宁可慢,不要假失败。
pub(crate) const UPDATE_CHECK_TIMEOUT_MS: u64 = 120_000;
/// `check_update` 的 RPC 超时(秒)。必须 > `UPDATE_CHECK_TIMEOUT_MS`(120s)。
pub(crate) const UPDATE_CHECK_RPC_TIMEOUT_SECS: u64 = 150;
/// 「下载更新包」下发给 sidecar 的出网超时(毫秒)。被控端约 1MB,300s 足够。
pub(crate) const UPDATE_DOWNLOAD_TIMEOUT_MS: u64 = 300_000;
/// `download` 的 RPC 超时(秒)。必须 > `UPDATE_DOWNLOAD_TIMEOUT_MS`(300s)。
pub(crate) const UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS: u64 = 330;
/// **编译期不变式**:内层出网超时必须小于外层 RPC 超时。
///
/// ## 为什么放在 `const _` 里而不是写成 `#[test]`
/// ① 这层关系一旦破坏,症状是「用户看到假失败、且失败原因指向别处」——
/// 让**构建**直接失败,比等某条单测被跑到更早、更不可能被绕过
/// (CI 的 `cargo clippy` / `cargo build` 都是闸门,而单测可能被 filter 掉)。
/// ② 两侧都是常量,写成断言函数会被 `clippy::assertions_on_constants` 拦下
/// (`-D warnings` 下即失败)—— clippy 给的正是本写法。
///
/// 注入验证(2026-10-13):把 `UPDATE_CHECK_RPC_TIMEOUT_SECS` 改成 50、
/// 或把 `UPDATE_CHECK_TIMEOUT_MS` 改回 20_000,**构建**即失败并打印下面的文案。
const _: () = {
assert!(
UPDATE_CHECK_TIMEOUT_MS < UPDATE_CHECK_RPC_TIMEOUT_SECS * 1000,
"check_update:出网超时必须小于 RPC 超时(否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败)"
);
assert!(
UPDATE_DOWNLOAD_TIMEOUT_MS < UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS * 1000,
"download:出网超时必须小于 RPC 超时"
);
// 60s 下限:真机上取 latest.json 的重定向链实测出现过 68.6s(三次为 2.0 / 10.0 / 68.6s),
// 回落到「假定能直连」时代的 20s 必然超时。
assert!(
UPDATE_CHECK_TIMEOUT_MS >= 60_000,
"检查更新的出网超时不得低于 60s"
);
};
// ---------------------------------------------------------------------------
// 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀
// ---------------------------------------------------------------------------
@@ -65,7 +127,7 @@ use tokio::sync::oneshot;
/// `\\?\` 路径 **0.5 秒**即退出、exit 1、**零 JS 执行**(连自带启动日志都没写出来)。
///
/// 控制台侧前后两种表现都由这一条派生:
/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等 45 秒 → 「check_update 超时」;
/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等到 RPC 超时(当时 45 秒) → 「check_update 超时」;
/// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。
///
/// ## 为什么单测 / CI / 开发模式全都测不出来
@@ -163,7 +225,7 @@ pub struct Sidecar {
/// ⚠️ 为什么不能只靠 `child.try_wait()`:往**已退出进程的管道**里写在 Windows 上
/// 未必立刻报错(管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
/// 真机实证(2026-10-13,Windows 11 ARM64):sidecar 早已退出,控制台却仍复用缓存里的
/// `Arc<Sidecar>`,点「检查更新」等满 45 秒 → 界面显示「检查更新失败:check_update 超时」;
/// `Arc<Sidecar>`,点「检查更新」等满 RPC 超时(当时 45 秒) → 界面显示「检查更新失败:check_update 超时」;
/// 而同一台机器上**手工拉起 sidecar 跑同一个请求只要 7 秒**。
/// 所以判定「它死了」要多路取证:进程状态 ∨ stdout EOF ∨ 写入失败。
dead: Arc<AtomicBool>,
@@ -252,7 +314,7 @@ impl Sidecar {
// 走到这里 = stdout 关闭(EOF 或读取出错)= sidecar 进程已结束。
dead.store(true, Ordering::SeqCst);
// 顺手唤醒**所有还在等响应**的请求,让它们立刻失败,
// 而不是干等各自的 timeout(check_update 45s / download 320s)。
// 而不是干等各自的 timeout(见 UPDATE_CHECK_RPC_TIMEOUT_SECS / UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS)。
// 没有这一步,sidecar 死掉的那一刻就会有请求白等好几分钟。
let mut waiting = pending.lock().unwrap();
for (_, tx) in waiting.drain() {
@@ -298,7 +360,7 @@ impl Sidecar {
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
// 入口先判存活 —— 这是「秒失败」的那一道。
// 只靠写入报错是不够的:Windows 上往已关闭的管道写可能被缓冲区吞掉而"成功",
// 之后就会一路干等到 timeout(真机形态:check_update 45s)。宁可在入口直接拒绝。
// 之后就会一路干等到 timeout(真机形态:check_update 的 RPC 超时,当时 45s)。宁可在入口直接拒绝。
if !self.is_alive() {
return Err(format!("sidecar 不可用(进程已退出),{method} 已放弃"));
}
@@ -450,7 +512,7 @@ mod tests {
//
// 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、
// 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报
// 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 45 秒超时」)。
// 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 RPC 超时」,当时 45 秒)。
// 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后,
// node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。
// 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。
@@ -600,7 +662,7 @@ mod tests {
// -----------------------------------------------------------------------
// 「sidecar 退出后不得被复用」—— 2026-10-13 真机缺陷
//
// 现象:控制台点「检查更新」等满 45 秒,界面报「check_update 超时」;
// 现象:控制台点「检查更新」等满 RPC 超时(当时 45 秒),界面报「check_update 超时」;
// 而同一台机器上手工拉起 sidecar 跑同一个请求只要 7 秒。
// 根因:控制台缓存里的 Arc<Sidecar> 指向早已退出的子进程,
// ensure_sidecar 只判「缓存里有没有」,不判「那进程还活着没」。
+5
View File
@@ -355,6 +355,11 @@ export interface UpdateInfo {
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
release_url: string;
source: string;
/** 本次出网走的代理来源(`HTTPS_PROXY` / `system` / `NA_PROXY`…),直连为 null。
* 2026-10-13 加:境内「检查更新」失败的排查线索,必须能在界面上看到。 */
via_proxy?: string | null;
/** 人类可读的出网路径描述(「直连」/「经代理 http://…」)。 */
via?: string | null;
checked_at: number;
}
+18 -2
View File
@@ -72,16 +72,27 @@ async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_age
async function doCheckUpdate() {
const u = await agent.checkUpdate();
if (agent.error) msg.error(`检查更新失败:${agent.error}`);
if (agent.error) msg.error(prefixOnce(agent.error, "检查更新失败:"));
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
}
async function doApplyUpdate() {
const r = await agent.applyUpdate();
if (agent.error) msg.error(`更新失败:${agent.error}`);
if (agent.error) msg.error(prefixOnce(agent.error, "更新失败:"));
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
}
/**
* 错误信息里若已带同一前缀就不再叠加。
*
* ⚠️ 2026-10-13:sidecar 侧把失败原因写成「检查更新失败:<真实原因>(经代理 …, 耗时 …)」,
* 而这里原本又拼一层 → 用户看到「检查更新失败:检查更新失败:请求超时…」。
* 但 `ensure_sidecar` 之类来自 Rust 壳的错误**没有**前缀,所以前缀本身要保留,只是不重复。
*/
function prefixOnce(text: string, prefix: string) {
return text.startsWith(prefix) ? text : prefix + text;
}
async function doConnectLocal() {
try {
await agent.connectLocal();
@@ -290,6 +301,11 @@ function openRelease() {
<div v-if="agent.update?.published_at" class="kv">
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
</div>
<!-- 出网路径:境内「检查更新」失败时最关键的一条排查线索(直连 / 哪个代理) -->
<div v-if="agent.update?.via" class="kv">
<span>出网</span>
<b class="mono ellipsis">{{ agent.update.via }}</b>
</div>
<n-space style="margin-top: 12px">
<n-button size="small" :loading="busy" @click="doCheckUpdate">检查更新</n-button>
+614
View File
@@ -0,0 +1,614 @@
/**
* 出站代理守卫(`apps/console/sidecar/proxy.mjs`)。
*
* ⚠️ 背景(真机缺陷,2026-10-13)
* 控制台「检查更新」在境内**必然**失败,报 `检查更新失败:fetch failed`。
* 根因不是网络,而是代码:Node 内置 `fetch`(undici)**默认不读代理环境变量**,
* 也读不到 Windows 系统代理(WinINET)/ macOS 系统代理 —— 而中文用户配的恰恰是
* 「系统代理」。真机取证:VM 上 DNS 与 TCP 443 都通、却 `fetch failed`;
* 同机经代理取 `latest.json` 返回 302。
*
* 因此本文件守四件事:
* ① **决议逻辑**(纯函数):代理来源优先级、`NO_PROXY`、系统代理形态解析;
* ② **真发请求**(本地服务器,离线可跑):直连 / 绝对形态 / CONNECT 隧道;
* ③ **端到端**:真 spawn sidecar 进程,用 JSON-RPC 跑 `check_update`,**确实经代理**;
* ④ **形态守卫**:sidecar 源码不得再出现裸 `fetch(`(防止修复被无声改回去)。
*
* 夹具都取自真机:macOS 的是本机 `scutil --proxy` 原样输出;Windows 的是 VM 上
* `reg query …\Internet Settings` 的原样输出(含「值不存在时 reg 走 stderr」的真形态)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
detectSystemProxy,
httpGet,
isBypassed,
normalizeProxyUrl,
parseRegQueryProxy,
parseScutilProxy,
pickEnvProxy,
resolveProxy,
tunnelConnection,
} from '../../apps/console/sidecar/proxy.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '../..');
const SIDECAR_SRC = join(repoRoot, 'apps/console/sidecar/main.mjs');
// ---------------------------------------------------------------------------
// 夹具(真机原样)
// ---------------------------------------------------------------------------
/** 本机(macOS)真实输出形态:透明代理扩展接管 → PAC 模式,HTTP/HTTPS 开关都是 0。 */
const SCUTIL_PAC = `<dictionary> {
ExceptionsList : <array> {
0 : localhost
1 : 127.0.0.0/8
2 : ::1
}
FTPPassive : 1
HTTPEnable : 0
HTTPSEnable : 0
ProxyAutoConfigEnable : 1
ProxyAutoConfigURLString : http://localhost:51431/proxy.pac
ProxyAutoDiscoveryEnable : 0
SOCKSEnable : 0
}
`;
/** 手工开系统代理时的形态(字段顺序与本机实测输出一致)。 */
const SCUTIL_ON = `<dictionary> {
ExceptionsList : <array> {
0 : *.local
1 : 169.254/16
}
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
HTTPSEnable : 1
HTTPSPort : 7891
HTTPSProxy : 127.0.0.1
ProxyAutoConfigEnable : 0
SOCKSEnable : 0
}
`;
/**
* 真机(Windows 11 ARM64 虚拟机)上 `reg query … /v ProxyEnable` 的真实输出形态(值为 0x0)。
*
* 注:本文件里的地址一律用**文档保留段**(RFC 5737 的 `192.0.2.0/24`)或回环,
* 不写任何真机的内网地址 —— 公开仓库的隐私审查会扫内网 IP(见 `security-reports/`)。
*/
const REG_OFF = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x0
`;
/** 开启系统代理的形态(与真机同一列宽:4 空格缩进 + 类型名 + 值)。 */
const REG_ON = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
ProxyOverride REG_SZ *.local;<local>
ProxyServer REG_SZ 192.0.2.10:7890
`;
const REG_PER_PROTOCOL = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
ProxyServer REG_SZ http=127.0.0.1:7890;https=127.0.0.1:7891
`;
// ---------------------------------------------------------------------------
// ① 纯函数
// ---------------------------------------------------------------------------
test('代理 URL 归一化:补 scheme、拒 socks、拒垃圾', () => {
assert.equal(normalizeProxyUrl('http://127.0.0.1:7890')?.href, 'http://127.0.0.1:7890/');
assert.equal(normalizeProxyUrl('127.0.0.1:7890')?.hostname, '127.0.0.1', '缺 scheme 应补 http://');
assert.equal(normalizeProxyUrl(' https://proxy.local:8443 ')?.protocol, 'https:');
assert.equal(normalizeProxyUrl('socks5://127.0.0.1:1080'), null, 'SOCKS 尚不支持 → 必须返回 null(上层据此出声)');
assert.equal(normalizeProxyUrl(''), null);
assert.equal(normalizeProxyUrl(' '), null);
assert.equal(normalizeProxyUrl(undefined), null);
assert.equal(normalizeProxyUrl('http://'), null, '没有主机名不算代理');
});
test('NO_PROXY 命中判定:精确 / 后缀 / 端口 / 通配 / 不误伤', () => {
const U = 'https://github.com/x';
assert.equal(isBypassed(U, ''), false);
assert.equal(isBypassed(U, ' '), false);
assert.equal(isBypassed(U, 'github.com'), true);
assert.equal(isBypassed(U, '.github.com'), true, '前导点等价于后缀匹配');
assert.equal(isBypassed('https://api.github.com/x', 'github.com'), true, '条目应覆盖其子域');
assert.equal(isBypassed(U, 'api.github.com'), false, '条目比目标更窄时不得反向豁免父域');
assert.equal(isBypassed(U, 'github.io'), false, '不同后缀不得误伤');
assert.equal(isBypassed(U, 'evilgithub.com'), false, '拼尾不得误伤');
assert.equal(isBypassed(U, 'github.com:443'), true, '端口相符应命中');
assert.equal(isBypassed(U, 'github.com:8080'), false, '端口不符不命中');
assert.equal(isBypassed(U, ' example.com , github.com '), true, '空白与多条目应容错');
assert.equal(isBypassed(U, '*'), true);
});
test('代理来源优先级:NA_PROXY > 协议环境变量 > ALL_PROXY', () => {
const U = 'https://github.com/x';
assert.equal(pickEnvProxy({ NA_PROXY: 'http://a:1', HTTPS_PROXY: 'http://b:2' }, U).source, 'NA_PROXY');
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2', ALL_PROXY: 'http://c:3' }, U).source, 'HTTPS_PROXY');
assert.equal(pickEnvProxy({ https_proxy: 'http://b:2' }, U).source, 'https_proxy', '小写形态同样生效');
assert.equal(pickEnvProxy({ ALL_PROXY: 'http://c:3' }, U).source, 'ALL_PROXY');
assert.equal(pickEnvProxy({}, U).url, null);
// 只看 http 目标时不认 HTTPS_PROXY
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2' }, 'http://x/y').url, null);
assert.equal(pickEnvProxy({ HTTP_PROXY: 'http://d:4' }, 'http://x/y').source, 'HTTP_PROXY');
});
test('环境变量写了 SOCKS 时必须出声,而不是静默直连', () => {
const r = pickEnvProxy({ HTTPS_PROXY: 'socks5://127.0.0.1:1080' }, 'https://github.com/x');
assert.equal(r.url, null);
assert.equal(r.notes.length, 1);
assert.match(r.notes[0], /socks5/, 'note 要带上原值,便于用户自查');
assert.match(r.notes[0], /已忽略/);
// 下一个可用来源仍应被采纳(不能因为前一个没看懂就整个放弃)
const r2 = pickEnvProxy({ HTTPS_PROXY: 'socks5://x:1', ALL_PROXY: 'http://ok:8080' }, 'https://github.com/x');
assert.equal(r2.source, 'ALL_PROXY');
});
test('解析 scutil:HTTPS 优先、PAC 模式出声、全关则无代理', () => {
const on = parseScutilProxy(SCUTIL_ON);
assert.equal(on.url, '127.0.0.1:7891', 'HTTPS 优先于 HTTP');
assert.equal(on.bypass, '*.local,169.254/16', 'ExceptionsList 应被收集成绕过列表');
assert.deepEqual(on.notes, []);
const pac = parseScutilProxy(SCUTIL_PAC);
assert.equal(pac.url, null);
assert.match(pac.notes.join(''), /PAC/, 'PAC 不可解析必须明说,否则用户只会看到 fetch failed');
const off = parseScutilProxy('<dictionary> {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n');
assert.equal(off.url, null);
assert.deepEqual(off.notes, [], '既没开代理也不是 PAC,就不该报噪音 note');
assert.equal(parseScutilProxy('not a dict'), null);
assert.equal(parseScutilProxy(''), null);
});
test('解析 reg query:0x0 即显式关闭、分协议形态取 https、<local> 剔除', () => {
const off = parseRegQueryProxy(REG_OFF);
assert.equal(off.url, null, 'ProxyEnable=0x0 是显式关闭,哪怕别的值有内容也不能启用');
const on = parseRegQueryProxy(REG_ON);
assert.equal(on.url, '192.0.2.10:7890');
assert.equal(on.bypass, '*.local', '<local> 是占位符,不是域名,必须剔除');
const per = parseRegQueryProxy(REG_PER_PROTOCOL);
assert.equal(per.url, '127.0.0.1:7891', '分协议形态应取 https(我们没有明文 http 目标)');
// 值不存在时 reg 把错误写在 stderr,stdout 只剩表头 —— 不该崩,也不该瞎猜
const missing = parseRegQueryProxy(
'\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\n\n',
);
assert.equal(missing, null);
assert.equal(parseRegQueryProxy('ERROR: The system was unable to find…'), null);
});
test('系统代理探测:命令失败/平台不支持一律视为「没有系统代理」', async () => {
// 探测失败绝不能让「检查更新」本身报错
assert.equal(await detectSystemProxy('win32', async () => null), null);
assert.equal(await detectSystemProxy('darwin', async () => null), null);
assert.equal(await detectSystemProxy('linux', async () => 'whatever'), null, 'Linux 不做猜测');
const boom = await detectSystemProxy('win32', async () => {
throw new Error('安全软件拦截');
});
assert.equal(boom, null, '抛异常也要吞掉');
// 真解析路径
const win = await detectSystemProxy('win32', async (file, args) => {
assert.equal(file, 'reg');
assert.equal(args[0], 'query');
assert.match(args[1], /Internet Settings$/, '必须查 HKCU 的 Internet Settings');
return REG_ON;
});
assert.equal(win.url, '192.0.2.10:7890');
assert.equal(
(await detectSystemProxy('darwin', async (f) => (f === 'scutil' ? SCUTIL_ON : null))).url,
'127.0.0.1:7891',
);
});
test('决议代理:NO_PROXY 短路 → 环境变量 → 系统代理兜底(含 note 累积)', async () => {
const U = 'https://github.com/x';
// NO_PROXY 直接短路(哪怕环境里有代理)
const bypass = await resolveProxy({ targetUrl: U, env: { HTTPS_PROXY: 'http://a:1', NO_PROXY: 'github.com' }, system: null });
assert.equal(bypass.proxy, null);
assert.match(bypass.notes.join(''), /NO_PROXY/);
// 环境变量优先于系统代理
const envFirst = await resolveProxy({
targetUrl: U,
env: { HTTPS_PROXY: 'http://env:1' },
system: { url: 'sys:2', bypass: '', notes: [] },
});
assert.equal(envFirst.proxy.href, 'http://env:1/');
assert.equal(envFirst.source, 'HTTPS_PROXY');
// 环境变量空 → 用系统代理,并把来源标成 system
const sys = await resolveProxy({
targetUrl: U,
env: {},
system: { url: '127.0.0.1:7890', bypass: '', notes: [] },
});
assert.equal(sys.proxy.href, 'http://127.0.0.1:7890/', '缺 scheme 的系统代理值也应能用');
assert.equal(sys.source, 'system');
// 系统代理的绕过列表命中 → 直连
const sysBypass = await resolveProxy({
targetUrl: U,
env: {},
system: { url: '127.0.0.1:7890', bypass: 'github.com', notes: [] },
});
assert.equal(sysBypass.proxy, null);
assert.match(sysBypass.notes.join(''), /绕过列表/);
// 完全没有代理:notes 应带出「为什么没用上」(PAC / socks),便于用户自查
const none = await resolveProxy({
targetUrl: U,
env: { HTTPS_PROXY: 'socks5://x:1' },
system: { url: null, bypass: '', notes: ['PAC 无法解析'] },
});
assert.equal(none.proxy, null);
assert.match(none.notes.join(';'), /socks5/);
assert.match(none.notes.join(';'), /PAC/);
});
// ---------------------------------------------------------------------------
// ② 真发请求(本地服务器,离线)
// ---------------------------------------------------------------------------
/** 起一个最小 HTTP 源站,返回 {url, close}。 */
async function startOrigin(handler) {
const srv = http.createServer(handler);
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
return { srv, port: srv.address().port, url: (p) => `http://127.0.0.1:${srv.address().port}${p}` };
}
/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(用于判定是否走了绝对形态)。 */
async function startHttpProxy() {
const seen = [];
const srv = http.createServer((req, res) => {
seen.push(req.url);
let u;
try {
u = new URL(req.url);
} catch {
res.writeHead(400);
res.end('not absolute-form');
return;
}
const up = http.request(
{ host: u.hostname, port: u.port, path: `${u.pathname}${u.search}`, method: req.method, headers: { ...req.headers, host: u.host } },
(r) => {
res.writeHead(r.statusCode, r.headers);
r.pipe(res);
},
);
up.on('error', () => {
res.writeHead(502);
res.end('upstream error');
});
req.pipe(up);
});
// CONNECT 也要支持:https 目标经代理走的就是它
srv.on('connect', (req, clientSock, head) => {
seen.push(`CONNECT ${req.url}`);
const [host, port] = req.url.split(':');
const up = net.connect(Number(port), host, () => {
clientSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head?.length) up.write(head);
up.pipe(clientSock);
clientSock.pipe(up);
});
up.on('error', () => clientSock.destroy());
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
return { srv, seen, url: `http://127.0.0.1:${srv.address().port}` };
}
test('httpGet 直连:跟随 302 且返回最终响应', async () => {
const origin = await startOrigin((req, res) => {
if (req.url === '/hop') {
res.writeHead(302, { location: '/final' });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ARRIVED');
});
try {
const r = await httpGet(origin.url('/hop'), { timeoutMs: 5000 });
assert.equal(r.status, 200);
assert.equal(r.body.toString(), 'ARRIVED');
assert.equal(r.url, origin.url('/final'), '应报出最终 URL');
} finally {
origin.srv.close();
}
});
test('httpGet 经代理:请求行走绝对形态(不是直连)', async () => {
const origin = await startOrigin((_req, res) => {
res.writeHead(200);
res.end('VIA-PROXY');
});
const proxy = await startHttpProxy();
try {
const r = await httpGet(origin.url('/latest.json'), { proxy: new URL(proxy.url), timeoutMs: 5000 });
assert.equal(r.body.toString(), 'VIA-PROXY');
assert.match(proxy.seen[0], /^http:\/\/127\.0\.0\.1:\d+\/latest\.json$/, 'HTTP 代理收到的是绝对 URI');
} finally {
proxy.srv.close();
origin.srv.close();
}
});
test('httpGet 经代理:代理不通时必须报错(证明真的走了代理而非偷偷直连)', async () => {
const origin = await startOrigin((_req, res) => {
res.writeHead(200);
res.end('SHOULD-NOT-BE-REACHED');
});
try {
await assert.rejects(
// 死端口:若实现「代理失败就自动直连」,这里会拿到 200 —— 那正是我们最怕的静默降级
httpGet(origin.url('/x'), { proxy: new URL('http://127.0.0.1:1'), timeoutMs: 5000 }),
/ECONNREFUSED|代理/,
);
} finally {
origin.srv.close();
}
});
test('abort 必须覆盖隧道与 TLS 握手:代理收下 CONNECT 却装死时,超时要立即生效', async () => {
// ⚠️ 真机踩过(2026-10-13):配置 75s 超时,实际挂了 **109s** 才失败。
// 根因:`createConnection` 回调返回之前外层 `http.request` 还没建立,
// 外层的 signal / timeout **无人受理**;若此时卡在 CONNECT 或 TLS 握手上,
// 超时形同虚设。本用例把那个形态固定下来:
// 代理回 200 之后就一个字都不说 → TLS 握手永远完不成。
const srv = http.createServer();
srv.on('connect', (_req, sock) => {
sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
// 装死:不再说一个字
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const t0 = Date.now();
let watchdog;
try {
await assert.rejects(
// 看门狗:没有它,一旦 abort 失效本用例会**挂死**(把整个测试套件拖住),
// 而不是干脆地变红。有它则 4s 后必定失败,且失败点落在下面的 dt 断言上。
Promise.race([
httpGet('https://github.com/x', {
proxy: new URL(`http://127.0.0.1:${srv.address().port}`),
timeoutMs: 800,
}),
new Promise((_, rej) => {
watchdog = setTimeout(() => rej(new Error('看门狗触发:abort 未生效')), 4000);
}),
]),
/abort|中止|超时/i,
);
const dt = Date.now() - t0;
assert.ok(dt < 4000, `超时应立即生效(配置 800ms),实际 ${dt}ms —— 说明 abort 没覆盖隧道/TLS 阶段`);
} finally {
clearTimeout(watchdog);
srv.closeAllConnections();
srv.close();
}
});
test('CONNECT 隧道:可用,且代理拒绝时给出明确错误', async () => {
const origin = await startOrigin((_req, res) => {
res.writeHead(200);
res.end('TUNNELED');
});
const proxy = await startHttpProxy();
try {
// 直接用隧道 socket 说一句裸 HTTP/1.1(这里刻意用 http 目标:隧道层不带 TLS,
// TLS 叠层由真机端到端验证覆盖 —— 见 CHANGELOG 的「未覆盖」记档)
const socket = await new Promise((resolve, reject) => {
const mk = tunnelConnection(new URL(proxy.url), { protocol: 'http:', hostname: '127.0.0.1', port: String(origin.srv.address().port) }, undefined);
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
});
const text = await new Promise((resolve) => {
let buf = '';
socket.on('data', (d) => {
buf += d;
if (buf.includes('TUNNELED')) {
socket.destroy();
resolve(buf);
}
});
socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
});
assert.match(text, /200 OK/);
assert.match(proxy.seen.join('|'), /^CONNECT 127\.0\.0\.1:\d+$/, '代理应收到 CONNECT');
// 代理拒绝(非 200)→ 必须立刻报错并关掉 socket,而不是挂着
const badProxy = http.createServer();
badProxy.on('connect', (_req, sock) => {
sock.write('HTTP/1.1 403 Forbidden\r\n\r\n');
sock.destroy();
});
await new Promise((r) => badProxy.listen(0, '127.0.0.1', r));
await assert.rejects(
new Promise((resolve, reject) => {
const mk = tunnelConnection(
new URL(`http://127.0.0.1:${badProxy.address().port}`),
{ protocol: 'http:', hostname: '127.0.0.1', port: '80' },
undefined,
);
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
}),
/代理拒绝 CONNECT/,
);
badProxy.close();
} finally {
proxy.srv.close();
origin.srv.close();
}
});
// ---------------------------------------------------------------------------
// ③ 端到端:真 spawn sidecar,真走 JSON-RPC
// ---------------------------------------------------------------------------
/** 驱动一个真 sidecar 进程;返回 {call, stderr, kill}。 */
function spawnSidecar(env) {
const child = spawn(process.execPath, [SIDECAR_SRC], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { PATH: process.env.PATH, HOME: process.env.HOME, ...env },
});
let out = '';
let err = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (err += d));
const call = (id, method, params) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等 ${method} 响应超时;stderr=${err.trim()}`)), 30_000);
const onData = () => {
for (const line of out.split('\n')) {
if (!line.trim().startsWith('{')) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id !== id) continue;
clearTimeout(timer);
child.stdout.off('data', onData);
resolve(msg);
}
};
child.stdout.on('data', onData);
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
});
return {
call,
stderr: () => err,
kill: () => {
try {
child.kill();
} catch {
/* 已退出 */
}
},
};
}
test('端到端:check_update 经代理拿到结果,并报出代理来源', async () => {
const meta = { version: '9.9.9', commit: 'deadbeef', built_at: '2026-10-13T00:00:00.000Z', agent: { url: 'http://x/agent.mjs', sha256: 'a'.repeat(64), bytes: 1 } };
const origin = await startOrigin((req, res) => {
if (req.url === '/latest.json') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(meta));
return;
}
res.writeHead(404);
res.end();
});
const proxy = await startHttpProxy();
const sc = spawnSidecar({ HTTP_PROXY: proxy.url });
try {
const msg = await sc.call(1, 'check_update', { console_version: '1.0.0', url: origin.url('/latest.json'), timeout_ms: 10_000 });
assert.equal(msg.error, undefined, `不应报错:${JSON.stringify(msg.error)} stderr=${sc.stderr()}`);
assert.equal(msg.result.latest_version, '9.9.9');
assert.equal(msg.result.has_update, true, '1.0.0 对 9.9.9 应有更新');
assert.equal(msg.result.via_proxy, 'HTTP_PROXY', '结果里要能看出这次走了哪条出网路径');
assert.match(proxy.seen[0], /\/latest\.json$/, '代理必须真收到这次请求');
// 反面对照:代理指到死端口 → 必须失败(证明它没有偷偷直连源站)
const sc2 = spawnSidecar({ HTTP_PROXY: 'http://127.0.0.1:1' });
try {
const bad = await sc2.call(1, 'check_update', { console_version: '1.0.0', url: origin.url('/latest.json'), timeout_ms: 5000 });
assert.ok(bad.error, '代理不可用时必须报错');
assert.match(bad.error.message, /检查更新失败/);
assert.match(bad.error.message, /经代理/, '错误信息要说清走了代理,否则用户无从下手');
assert.match(bad.error.message, /ECONNREFUSED|超时/);
assert.equal(bad.error.data.proxy, 'http://127.0.0.1:1/', '失败的线索里要带上代理地址');
} finally {
sc2.kill();
}
} finally {
sc.kill();
proxy.srv.close();
origin.srv.close();
}
});
test('错误映射:连接被中途重置时不得报成「请求超时」(真机踩过)', async () => {
// ⚠️ 真机形态(2026-10-13):耗时 82.9s(预算 120s),连接是被对端**中途重置**的,
// 却被报成「请求超时(120000ms 内未完成)」—— 一句话把用户引向完全错误的方向。
// 根因是把 `ECONNRESET` 也归进了「abort 类」。这里用「收到请求立刻 RST」的源站固定该形态。
const srv = http.createServer((_req, res) => {
res.socket?.destroy();
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const sc = spawnSidecar({});
try {
const msg = await sc.call(1, 'check_update', {
console_version: '1.0.0',
url: `http://127.0.0.1:${srv.address().port}/latest.json`,
timeout_ms: 10_000, // 预算 10s:真超时也要 10s 才会到,故「没到预算就报超时」必是误判
});
assert.ok(msg.error, '连接被重置必须报错');
assert.ok(
!/请求超时/.test(msg.error.message),
`不得把「连接被重置」报成「请求超时」:${msg.error.message}`,
);
assert.match(
msg.error.message,
/中途断开|ECONNRESET|socket hang up/,
`应说明是连接被对端断开:${msg.error.message}`,
);
} finally {
sc.kill();
srv.closeAllConnections();
srv.close();
}
});
// ---------------------------------------------------------------------------
// ④ 形态守卫
// ---------------------------------------------------------------------------
/**
* sidecar 源码不得再出现**裸 `fetch(`**。
*
* 这是一条**源码形态**守卫(而非产物字节守卫)—— 因为这里要守的规则本身就是
* 「不许调用这个 API」:`fetch` 在 Node 上不认代理,一旦有人图省事写回去,
* 代理支持会被**静默**废掉,而所有现有测试都会继续绿。
* 行为层面的守卫是上面的「端到端」用例;两者互补。
*
* ⚠️ 例外说明:`apps/agent/src/capabilities/selfupdate.ts` 仍在用 `fetch`,
* 那是被控端自更新链路,项目既定策略是「改镜像地址」而不是塞代理隧道
* (见该文件顶部注释)。本守卫**只覆盖 sidecar**,不越界。
*/
test('形态守卫:sidecar 不得再用裸 fetch(否则代理支持会被静默废掉)', () => {
const src = readFileSync(SIDECAR_SRC, 'utf8');
const bad = src
.split('\n')
.map((line, i) => ({ line, no: i + 1 }))
.filter(({ line }) => /(^|[^.\w])fetch\s*\(/.test(line) && !line.trim().startsWith('*') && !line.trim().startsWith('//'));
assert.deepEqual(
bad.map((b) => `${b.no}: ${b.line.trim()}`),
[],
'sidecar 内所有出网必须走 proxy.mjs 的 httpGet(Node 的 fetch 不读代理设置)',
);
assert.match(src, /from '\.\/proxy\.mjs'/, '必须真的引入 proxy.mjs');
});