Files
nodeagent/tests/unit/sidecar-proxy.test.mjs
T
jkooe b34e8c73e0 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`。
2026-10-13 22:41:02 +08:00

615 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 出站代理守卫(`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');
});