Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/runtime/guest-error.test.ts
Original file line number Diff line number Diff line change
@@ -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://");
});
});
73 changes: 73 additions & 0 deletions src/runtime/guest-error.ts
Original file line number Diff line number Diff line change
@@ -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<object> {
const seen = new Set<unknown>();
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}`;
}
12 changes: 12 additions & 0 deletions src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -636,7 +637,18 @@ export async function runTui(args: ServeArgs): Promise<void> {
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);
Expand Down