fix(baoyu-post-to-wechat): make remote-api work under Bun & validate config strictly

The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.

P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
  + `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
  line parser, case-insensitive headers, chunked & content-length body
  framing). Works identically under Node and Bun because it avoids
  `http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
  a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
  functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
  `client: WechatClient = wechatHttp` on the five HTTP-touching
  functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
  stub + HTTP echo server and asserts `connectionCount === 1`,
  proving bytes actually traverse the proxy under both runtimes.

P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
  back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
  use it, restoring test isolation.

P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
  `parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
  the key name. `loadWechatExtendConfig` only catches file-read
  errors so parse errors surface to the caller. Flip the corresponding
  test cases.

Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.

Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
Jim Liu 宝玉
2026-05-21 02:10:23 -05:00
parent 3b29f3c57c
commit e0b861c148
10 changed files with 586 additions and 132 deletions
@@ -1,12 +1,7 @@
import http from "node:http";
import https from "node:https";
import { URL } from "node:url";
export interface WechatHttpInit {
method?: string;
headers?: Record<string, string>;
body?: string | Buffer;
agent?: http.Agent | https.Agent | false;
}
export interface WechatHttpResponse {
@@ -18,6 +13,11 @@ export interface WechatHttpResponse {
json<T = unknown>(): Promise<T>;
}
export type WechatClient = (
url: string,
init?: WechatHttpInit,
) => Promise<WechatHttpResponse>;
export interface MultipartFilePart {
name: string;
filename: string;
@@ -51,56 +51,47 @@ export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
};
}
export async function wechatHttp(url: string, init: WechatHttpInit = {}): Promise<WechatHttpResponse> {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const transport = isHttps ? https : http;
const body = init.body == null
? undefined
: Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body, "utf-8");
const headers: Record<string, string> = { ...(init.headers ?? {}) };
if (body && headers["Content-Length"] === undefined && headers["content-length"] === undefined) {
headers["Content-Length"] = String(body.length);
}
const requestOptions: https.RequestOptions = {
method: init.method ?? (body ? "POST" : "GET"),
hostname: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
path: `${parsed.pathname}${parsed.search}`,
headers,
};
if (init.agent !== undefined) {
requestOptions.agent = init.agent;
}
return new Promise((resolve, reject) => {
const req = transport.request(requestOptions, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
const bodyBuffer = Buffer.concat(chunks);
resolve({
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? "",
headers: res.headers as Record<string, string | string[] | undefined>,
async buffer() {
return bodyBuffer;
},
async text() {
return bodyBuffer.toString("utf-8");
},
async json<T = unknown>() {
return JSON.parse(bodyBuffer.toString("utf-8")) as T;
},
});
});
res.on("error", reject);
});
req.on("error", reject);
if (body) req.write(body);
req.end();
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
const out: Record<string, string | string[] | undefined> = {};
headers.forEach((value, key) => {
const existing = out[key];
if (existing === undefined) {
out[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
out[key] = [existing, value];
}
});
return out;
}
export const wechatHttp: WechatClient = async (url, init = {}) => {
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
const headers: Record<string, string> = { ...(init.headers ?? {}) };
let body: BodyInit | undefined;
if (init.body !== undefined) {
body = Buffer.isBuffer(init.body)
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
: init.body;
}
const res = await fetch(url, { method, headers, body });
const buf = Buffer.from(await res.arrayBuffer());
return {
status: res.status,
statusText: res.statusText,
headers: headersToRecord(res.headers),
async buffer() {
return buf;
},
async text() {
return buf.toString("utf-8");
},
async json<T = unknown>() {
return JSON.parse(buf.toString("utf-8")) as T;
},
};
};