From aae873fb9ec6bb93389e314ced2471c8b6523253 Mon Sep 17 00:00:00 2001 From: WASD1999xl <2960525002@qq.com> Date: Sun, 6 Sep 2026 15:44:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(tui):=20guest=20=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=20SDK=20=E6=8A=A5=E9=94=99=E4=B8=8D=E5=86=8D=E7=BB=88=E6=AD=A2?= =?UTF-8?q?=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场报告(v4.5.5,多次复现,间隔十几到二十几分钟): zcode-proxy: tui crashed: ReferenceError: moveBy is not defined at tE (https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin008….js:1:169658) Bun 下 happy-dom 的 VM 隔离不生效,Aliyun/FeiLin 脚本直接运行在宿主 realm, 因此从它们的回调里逃逸的异常会以进程级 uncaughtException 到达,与我们自己的 故障无从区分。serve 模式早已划清这条界线(captcha-happy.ts 记录后继续运行, 单次求解失败由池重试);TUI 的处理器却对一切 exit(1),把一个可恢复故障放大成 了整个代理进程死亡。 新增 runtime/guest-error.ts 按抛出帧的来源判定归属:仅当栈/sourceURL/message 中出现 SDK 的 CDN 主机才算 guest。判据锚定在 URL authority 上并要求分隔符 收尾,所以 `evil-alicdn.com.attacker.net` 与 `?ref=alicdn.com` 都不算证据; 同时跟随 cause 链(带自引用保护)。取证不足一律判为宿主故障——沉默地吞掉真实 崩溃比误杀更糟。 TUI 据此放行 guest 报错并写入日志面板,其余仍照旧 cleanup + exit(1)。 验证:把现场那条栈注入 stock captcha-happy,TUI 存活且面板出现 "ReferenceError: moveBy is not defined (tE (feilin008….js))";真实内部故障 仍然退出。 --- src/runtime/guest-error.test.ts | 77 +++++++++++++++++++++++++++++++++ src/runtime/guest-error.ts | 73 +++++++++++++++++++++++++++++++ src/tui/app.ts | 12 +++++ 3 files changed, 162 insertions(+) create mode 100644 src/runtime/guest-error.test.ts create mode 100644 src/runtime/guest-error.ts diff --git a/src/runtime/guest-error.test.ts b/src/runtime/guest-error.test.ts new file mode 100644 index 0000000..e970203 --- /dev/null +++ b/src/runtime/guest-error.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { describeGuestError, isGuestOriginError } from "./guest-error.js"; + +/** The exact stack from the field crash report (v4.5.5, TUI mode). */ +function fieldCrash(): Error { + const err = new ReferenceError("moveBy is not defined"); + err.stack = + "ReferenceError: moveBy is not defined\n" + + " at tE (https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/" + + "feilin008.489f1fe42772a97d510f162902b33c35c004ce7746ca3f4696c42f8d1f677670.js:1:169658)"; + return err; +} + +function withStack(message: string, stack: string): Error { + const err = new Error(message); + err.stack = stack; + return err; +} + +describe("guest-origin classification", () => { + test("the captcha SDK crash that killed the TUI is attributed to the guest", () => { + expect(isGuestOriginError(fieldCrash())).toBe(true); + }); + + test("host faults stay fatal", () => { + // The v4.5.2 crash shape: a Bun-internal timer stripped of `.unref()`. + // It is a real proxy defect and must never be swallowed as guest noise. + const unref = withStack( + "setTimeout(() => {}, 0).unref is not a function", + "TypeError: …unref is not a function\n at node:_http_server:512:9", + ); + expect(isGuestOriginError(unref)).toBe(false); + expect( + isGuestOriginError(withStack("boom", "Error: boom\n at buildFrame (/app/src/tui/frame.ts:88:3)")), + ).toBe(false); + }); + + test("an unattributable error is treated as a host fault", () => { + // No evidence must never mean "assume guest": that would silently mask + // genuine crashes behind a log line. + expect(isGuestOriginError(new Error("no stack recorded"))).toBe(false); + expect(isGuestOriginError({ message: "not even an Error" })).toBe(false); + expect(isGuestOriginError(undefined)).toBe(false); + }); + + test("lookalike hosts are not guest evidence", () => { + expect( + isGuestOriginError(withStack("x", "at f (https://evil-alicdn.com.attacker.net/x.js:1:1)")), + ).toBe(false); + expect( + isGuestOriginError(withStack("x", "at f (https://cdn.example.com/p?ref=alicdn.com)")), + ).toBe(false); + }); + + test("every host serving the SDK counts, including subdomains and cause chains", () => { + expect(isGuestOriginError(withStack("x", "at https://o.alicdn.com/captcha-frontend/a.js:1:1"))).toBe(true); + expect(isGuestOriginError(withStack("x", "at https://captcha.ap-southeast-1.aliyuncs.com/x.js:1:1"))).toBe(true); + const wrapped = withStack("wrapper", "Error: wrapper\n at host (/app/x.ts:1:1)"); + (wrapped as { cause?: unknown }).cause = fieldCrash(); + expect(isGuestOriginError(wrapped)).toBe(true); + }); + + test("a self-referential cause chain terminates", () => { + const a = withStack("a", "at /app/a.ts:1:1"); + const b = withStack("b", "at /app/b.ts:1:1"); + (a as { cause?: unknown }).cause = b; + (b as { cause?: unknown }).cause = a; + expect(isGuestOriginError(a)).toBe(false); + }); + + test("the log line names the error and the bundle, without the content hash", () => { + const line = describeGuestError(fieldCrash()); + expect(line).toContain("ReferenceError: moveBy is not defined"); + expect(line).toContain("feilin008"); + expect(line).not.toContain("https://"); + }); +}); diff --git a/src/runtime/guest-error.ts b/src/runtime/guest-error.ts new file mode 100644 index 0000000..145ee1e --- /dev/null +++ b/src/runtime/guest-error.ts @@ -0,0 +1,73 @@ +/** + * guest-error.ts — provenance test for errors thrown by third-party captcha + * SDK code ("guest" scripts) running inside the in-process happy-dom solver. + * + * Under Bun the guest scripts execute in the HOST realm (happy-dom's VM + * isolation is a no-op there), so an error escaping a guest callback reaches + * process-level `uncaughtException` exactly like an error from our own code. + * The two must not share a fate: a rotated pe/FeiLin bundle tripping over an + * emulation gap must fail that one solve — the pool retries — while a genuine + * fault in the proxy still terminates loudly. + * + * `serve` mode already draws that line (captcha-happy.ts logs and continues). + * The TUI's own uncaughtException handler used to exit(1) for everything, + * which turned a recoverable guest error into a dead proxy: + * + * zcode-proxy: tui crashed: ReferenceError: moveBy is not defined + * at tE (https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin008…js) + * + * Evidence is the source URL of the throwing frame: guest bundles are served + * from Aliyun CDN/API hosts and carry those URLs in their stack frames (and, + * for generated code, in the `//# sourceURL` we preserve when evaluating). + */ + +// CDN/API hosts serving the Aliyun captcha SDK bundles. Anchored at the URL +// authority (`scheme://`, optional userinfo) and terminated by a port/path/ +// delimiter, so the domain must be the real host — `evil-alicdn.com.bad.net` +// and `…?ref=alicdn.com` are not guest evidence. +const GUEST_SCRIPT_HOST = + /https?:\/\/(?:[^/\s@]*@)?(?:[a-z0-9-]+\.)*(?:alicdn\.com|aliyuncs\.com)(?=[:/?#]|\s|$)/i; + +/** Follows `cause` chains without looping on self-referential errors. */ +function* errorChain(err: unknown, depth = 4): Generator { + const seen = new Set(); + let current = err; + for (let i = 0; i < depth; i++) { + if (!current || typeof current !== "object" || seen.has(current)) return; + seen.add(current); + yield current as object; + current = (current as { cause?: unknown }).cause; + } +} + +/** + * True when `err` originates in third-party captcha SDK code. + * + * Deliberately conservative: only a guest source URL counts as evidence. A + * missing/truncated stack yields `false`, so an unattributable error keeps the + * strict (fatal) treatment. + */ +export function isGuestOriginError(err: unknown): boolean { + for (const link of errorChain(err)) { + const e = link as { stack?: unknown; sourceURL?: unknown; message?: unknown }; + if (typeof e.stack === "string" && GUEST_SCRIPT_HOST.test(e.stack)) return true; + // Bun/JSC attach the throwing script's URL directly on the error. + if (typeof e.sourceURL === "string" && GUEST_SCRIPT_HOST.test(e.sourceURL)) return true; + if (typeof e.message === "string" && GUEST_SCRIPT_HOST.test(e.message)) return true; + } + return false; +} + +/** One-line render of a guest error for the log pane / stderr. */ +export function describeGuestError(err: unknown): string { + const e = err as { name?: unknown; message?: unknown; stack?: unknown } | null; + const name = typeof e?.name === "string" ? e.name : "Error"; + const message = typeof e?.message === "string" ? e.message : String(err); + const frame = + typeof e?.stack === "string" + ? (e.stack.split("\n").find((line) => GUEST_SCRIPT_HOST.test(line)) ?? "").trim() + : ""; + // Bundle URLs carry a 64-char content hash — keep the readable filename only. + const source = frame.replace(/^at\s+/, "").replace(/https?:\/\/[^\s)]*\/([^/\s)]+)/, "$1"); + return source ? `${name}: ${message} (${source})` : `${name}: ${message}`; +} diff --git a/src/tui/app.ts b/src/tui/app.ts index e479b1b..c72f38c 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -23,6 +23,7 @@ import { ZaiOAuthClient, BigmodelOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallb import { KeyResolver } from "../auth/resolver.js"; import { openBrowser } from "../runtime/open-browser.js"; import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "../runtime/paste-login.js"; +import { isGuestOriginError, describeGuestError } from "../runtime/guest-error.js"; import { ensureDeviceMidInConfig, VERSION, type ServeArgs } from "../index.js"; import { writeFileSync, existsSync, appendFileSync } from "node:fs"; import type { ProxyConfig } from "../config/types.js"; @@ -636,7 +637,18 @@ export async function runTui(args: ServeArgs): Promise { process.on("SIGTERM", quit); process.on("exit", restore); stdout.on("resize", scheduleRender); + // Guest captcha-SDK errors must NOT kill the TUI. Under Bun the Aliyun/ + // FeiLin bundles run in the HOST realm, so an error escaping one of their + // stray callbacks arrives here exactly like an internal fault. `serve` mode + // already logs-and-continues (captcha-happy.ts); the TUI used to exit(1) for + // everything, turning a retryable solve failure into a dead proxy (field + // report: "tui crashed: ReferenceError: moveBy is not defined" from + // feilin008.js). The pool retries the solve; the proxy keeps serving. process.on("uncaughtException", (err: Error) => { + if (isGuestOriginError(err)) { + emit(`[warn] captcha SDK error (ignored): ${describeGuestError(err)}`, "warn"); + return; + } cleanup(); origError(`zcode-proxy: tui crashed: ${err.stack ?? String(err)}`); process.exit(1);