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);