From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 1/4] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab..6547da6552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 5f39782970ca027e4345137c08c3c7aea6b5090b Mon Sep 17 00:00:00 2001 From: Eros Date: Sat, 12 Sep 2026 20:01:55 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat(providers):=20ZCode=20=E2=80=94=20Z.ai?= =?UTF-8?q?=20Start=20Plan=20provider=20(OAuth=20login,=20traceless=20capt?= =?UTF-8?q?cha,=20client=20attribution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a native `zcode-start-plan` provider that serves the Z.ai Start Plan quota from the ZCode plan gateway (zcode.z.ai) with no ZCode desktop installation required. OAuth login - `ocx login zcode-start-plan` drives the gateway's OAuth CLI flow (init -> browser authorize -> poll) and stores the plan JWT in the ocx auth store. The JWT has no exp claim and no silent refresh: gateway rejections are terminal and surface as re-login. Adapter - Anthropic-format requests to /api/v1/zcode-plan/anthropic/v1/messages with `Authorization: Bearer ` + `anthropic-version` only — the route is exempt from the client's V4 request signing. - Identity and attribution headers mirror the official client (ZCode UA with the ai-sdk/anthropic suffix, X-ZCode-Agent last, per-request x-request-id/x-zcode-trace-id, x-zcode-session-type: main). - The gateway inspects the request body: the official ZCode system blocks are prepended to `system` (powered-by line merged into the Environment block), two-phase cache_control marking, and metadata.user_id decoded from the JWT — without them the gateway answers biz code 3012. - Aliyun WAF captcha challenges (biz 3007 in-body or via the x-aliyun-captcha-verify-param response header) mint a verify param with an in-process happy-dom traceless solver (deterministic fingerprint, gateway cookie priming, CDN cache, guest-realm timer scoping) and the request is replayed once with the verify headers. Biz errors inside HTTP 200 (e.g. 1005 rate limit) are mapped to real statuses (429/502); a 3012 WAF block surfaces as upstream_error instead of a truncated stream. Registry - Preset on the anthropic-compatible gateway route, authKind oauth, featured; GLM-5.3/Flash/5.2/5-Turbo with Flash advertised as text+image; liveModels disabled (the route has no /models listing). - Plan-metered GLM destinations (api.z.ai and open.bigmodel.cn coding paths, BigModel Responses v1) now send the same ZCode client identity headers: Z.ai grants ZCode-attributed traffic an increased (150%) usage allowance. Pay-as-you-go /api/paas/v4 is excluded. Quota - Per-account probe of billing/balance (requires the X-Device-Mid header; its absence answers biz code 3001) surfacing balance rows as custom quota windows. GUI - Request Logs gain an Account column resolving the opaque per-account log labels to emails/plan via GET /api/account-labels (masked per the privacy.maskEmails setting), with provider already recorded per row. Dependency: happy-dom (captcha solver). Tests in tests/providers/zcode-start-plan.test.ts. --- bun.lock | 15 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 37 +- gui/src/styles.css | 11 +- package.json | 1 + scripts/dev-gateway-validate.ts | 41 + scripts/dev-mint-test.ts | 8 + src/adapters/openai-chat.ts | 8 + src/adapters/openai-responses.ts | 9 +- src/adapters/registry.ts | 5 + src/adapters/zcode-identity.ts | 98 + src/adapters/zcode-start-plan.ts | 212 ++ .../zcode-start-plan/body-transform.ts | 137 + .../zcode-start-plan/captcha-solver.ts | 2282 +++++++++++++++++ .../zcode-start-plan/system-blocks.json | 23 + src/oauth/index.ts | 10 + src/oauth/zcode-start-plan.ts | 117 + src/providers/quota.ts | 116 +- src/providers/registry.ts | 29 + src/server/management/oauth-account-routes.ts | 41 +- tests/providers/zcode-start-plan.test.ts | 152 ++ 29 files changed, 3350 insertions(+), 11 deletions(-) create mode 100644 scripts/dev-gateway-validate.ts create mode 100644 scripts/dev-mint-test.ts create mode 100644 src/adapters/zcode-identity.ts create mode 100644 src/adapters/zcode-start-plan.ts create mode 100644 src/adapters/zcode-start-plan/body-transform.ts create mode 100644 src/adapters/zcode-start-plan/captcha-solver.ts create mode 100644 src/adapters/zcode-start-plan/system-blocks.json create mode 100644 src/oauth/zcode-start-plan.ts create mode 100644 tests/providers/zcode-start-plan.test.ts diff --git a/bun.lock b/bun.lock index 6161766917..4e56bb0edd 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", "bun": "1.4.2", + "happy-dom": "^20.14.3", "zod": "4.4.3", }, "devDependencies": { @@ -88,6 +89,10 @@ "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -136,6 +141,8 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], @@ -168,6 +175,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -204,6 +213,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "happy-dom": ["happy-dom@20.14.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-0KMb/Eh8rsd+aMNkOk5h8LkAjo9Na1aksnEGmsuZf+GXl6UkAdIi0VbUHU7d59lXhNmawODgwwuCMiZpCxwwCw=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], @@ -306,10 +317,14 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 68363152b5..b252d58c13 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -748,6 +748,7 @@ export const de: Record = { "logs.col.model": "Modell", "logs.col.effort": "Aufwand", "logs.col.provider": "Anbieter", +"logs.col.account": "Konto", "logs.col.status": "Status", "logs.col.tokens": "Tokens", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 89fc5ec0d7..3995c3e015 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -797,6 +797,7 @@ export const en = { "logs.col.model": "Model", "logs.col.effort": "Effort", "logs.col.provider": "Provider", +"logs.col.account": "Account", "logs.col.status": "Status", "logs.col.tokens": "Tokens", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4cf6818072..f830b1ef0b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -778,6 +778,7 @@ export const fr: Record = { "logs.col.model": "Modèle", "logs.col.effort": "Niveau", "logs.col.provider": "Fournisseur", +"logs.col.account": "Compte", "logs.col.status": "État", "logs.col.tokens": "Jetons", "logs.col.tokPerSec": "jetons/s", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c77edbe2c7..db1c9ca88e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -710,6 +710,7 @@ export const ja: Record = { "logs.col.model": "モデル", "logs.col.effort": "負荷", "logs.col.provider": "プロバイダー", +"logs.col.account": "アカウント", "logs.col.status": "状態", "logs.col.tokens": "トークン", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 34d5ceae87..d43a6d8b13 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -779,6 +779,7 @@ export const ko: Record = { "logs.col.model": "모델", "logs.col.effort": "추론 강도", "logs.col.provider": "프로바이더", +"logs.col.account": "계정", "logs.col.status": "상태", "logs.col.tokens": "토큰", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 488f87d55b..5988aac381 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -765,6 +765,7 @@ export const ru: Record = { "logs.col.model": "Модель", "logs.col.effort": "Уровень", "logs.col.provider": "Провайдер", +"logs.col.account": "Аккаунт", "logs.col.status": "Статус", "logs.col.tokens": "Токены", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2e46e2792f..9e3889ca0e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -784,6 +784,7 @@ export const tr: Record = { "logs.col.model": "Model", "logs.col.effort": "Çaba", "logs.col.provider": "Sağlayıcı", +"logs.col.account": "Hesap", "logs.col.status": "Durum", "logs.col.tokens": "Jetonlar", "logs.col.tokPerSec": "jeton/sn", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b9a26da41c..373b6f52d6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -614,6 +614,7 @@ export const zhTW: Record = { "logs.col.model": "模型", "logs.col.effort": "推理強度", "logs.col.provider": "供應商", +"logs.col.account": "帳戶", "logs.col.status": "狀態", "logs.col.tokens": "Token 數", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 46866680b0..602bff0237 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -760,6 +760,7 @@ export const zh: Record = { "logs.col.model": "模型", "logs.col.effort": "推理强度", "logs.col.provider": "提供方", +"logs.col.account": "账户", "logs.col.status": "状态", "logs.col.tokens": "Token 数", "logs.col.tokPerSec": "tok/s", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 774efc455a..f0998336b4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -142,6 +142,8 @@ export interface LogEntry { timestamp: number; model: string; provider: string; + /** Pool/account label the turn was served under (e.g. "p83fa8d", "main"); absent when unattributed. */ + accountLogLabel?: string; surface?: LogSurface; conversationId?: string; /** @@ -443,6 +445,34 @@ export default function Logs({ apiBase }: { apiBase: string }) { controller.abort(); }; }, [apiBase]); + // Opaque log labels → human attribution (email masked per proxy privacy settings). + // Fetched once per page: labels are stable for the lifetime of an account. + const [accountLabels, setAccountLabels] = useState>(new Map()); + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + fetch(`${apiBase}/api/account-labels`, { signal: controller.signal }) + .then(res => (res.ok ? res.json() as Promise<{ labels?: Array<{ label?: unknown; email?: unknown; plan?: unknown }> }> : null)) + .then(body => { + if (cancelled || !body?.labels) return; + const map = new Map(); + for (const row of body.labels) { + if (typeof row.label !== "string" || !row.label) continue; + const parts: string[] = []; + if (typeof row.email === "string" && row.email) parts.push(row.email); + if (typeof row.plan === "string" && row.plan) parts.push(row.plan); + if (parts.length > 0) map.set(row.label, parts.join(" · ")); + } + setAccountLabels(map); + }) + .catch(() => { + // Older proxy without the endpoint: fall back to the raw opaque labels. + }); + return () => { + cancelled = true; + controller.abort(); + }; + }, [apiBase]); // The hash is the source of truth for the active tab (#logs vs #logs/debug), // so refresh/bookmark/back-forward keep the tab choice. const [tab, setTab] = useState(readTabFromHash); @@ -752,6 +782,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { + @@ -765,6 +796,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.col.model")} {t("logs.col.effort")} {t("logs.col.provider")} + {t("logs.col.account")} {t("logs.col.status")} {t("logs.col.request")} {t("logs.col.duration")} @@ -773,7 +805,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {paddingTop > 0 && ( - + )} {virtualRows.map(virtualRow => { @@ -858,6 +890,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { 9% column and painted over the provider cell. */} {effortLabel(log)} {formatProviderDisplayName(log.provider, t)} + {accountLabels.get(log.accountLogLabel ?? "") ?? log.accountLogLabel ?? "—"} {log.status} @@ -878,7 +911,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { })} {paddingBottom > 0 && ( - + )} diff --git a/gui/src/styles.css b/gui/src/styles.css index 1a51a2f40e..c729ef8b2b 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2109,13 +2109,14 @@ table.logs-table { min-width: 1100px; table-layout: fixed; } -.logs-table col.logs-col-time { width: 12%; } -.logs-table col.logs-col-tokens { width: 9%; } +.logs-table col.logs-col-time { width: 10%; } +.logs-table col.logs-col-tokens { width: 8%; } .logs-table col.logs-col-rate { width: 7%; } .logs-table col.logs-col-cost { width: 8%; } -.logs-table col.logs-col-model { width: 15%; } -.logs-table col.logs-col-effort { width: 9%; } -.logs-table col.logs-col-provider { width: 13%; } +.logs-table col.logs-col-model { width: 13%; } +.logs-table col.logs-col-effort { width: 8%; } +.logs-table col.logs-col-provider { width: 11%; } +.logs-table col.logs-col-account { width: 8%; } .logs-table col.logs-col-status { width: 8%; } .logs-table col.logs-col-request { width: 11%; } .logs-table col.logs-col-duration { width: 8%; } diff --git a/package.json b/package.json index 936324ccec..9f5e77caf0 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", "bun": "1.4.2", + "happy-dom": "^20.14.3", "zod": "4.4.3" }, "devDependencies": { diff --git a/scripts/dev-gateway-validate.ts b/scripts/dev-gateway-validate.ts new file mode 100644 index 0000000000..cc409d8807 --- /dev/null +++ b/scripts/dev-gateway-validate.ts @@ -0,0 +1,41 @@ +// One-shot gateway validation through the REAL adapter code path (Bun fetch). +// Run: bun scripts/dev-gateway-validate.ts (dev-only, not shipped in the PR) +import { readFileSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { buildLlmIdentityHeaders, buildTraceHeaders } from "../src/adapters/zcode-start-plan"; +import { transformStartPlanBody, userIdFromJwt } from "../src/adapters/zcode-start-plan/body-transform"; +import { solveTraceless } from "../src/adapters/zcode-start-plan/captcha-solver"; + +const store = JSON.parse(readFileSync("/home/eros/.opencodex/auth.json", "utf8")); +const jwt = store["zcode-start-plan"].accounts[0].credential.access as string; +const userId = userIdFromJwt(jwt); + +const param = await solveTraceless({ scene: "11xygtvd", region: "sgp", prefix: "no8xfe", timeoutMs: 30_000 }); +writeFileSync("/tmp/captcha-param.txt", param); +console.error("minted", param.length); + +const model = "GLM-5.3-Flash"; +const body = transformStartPlanBody( + JSON.stringify({ model, max_tokens: 16, stream: false, messages: [{ role: "user", content: "say OK" }] }), + model, + userId, +); + +const res = await fetch("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages", { + method: "POST", + redirect: "manual", + headers: { + ...buildLlmIdentityHeaders(), + ...buildTraceHeaders(), + authorization: `Bearer ${jwt}`, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "X-Aliyun-Captcha-Verify-Param": param, + "X-Aliyun-Captcha-Verify-Region": "sgp", + }, + body, +}); +const text = await res.text(); +console.log("HTTP", res.status); +console.log(text.slice(0, 600)); +process.exit(res.ok ? 0 : 1); diff --git a/scripts/dev-mint-test.ts b/scripts/dev-mint-test.ts new file mode 100644 index 0000000000..e90e545fcc --- /dev/null +++ b/scripts/dev-mint-test.ts @@ -0,0 +1,8 @@ +// Standalone mint harness: exercises the solver without touching the gateway. +// Run: bun scripts/dev-mint-test.ts (dev-only, not shipped in the PR) +import { writeFileSync } from "node:fs"; +import { solveTraceless } from "../src/adapters/zcode-start-plan/captcha-solver"; + +const param = await solveTraceless({ scene: "11xygtvd", region: "sgp", prefix: "no8xfe", timeoutMs: 30000 }); +writeFileSync("/tmp/captcha-param.txt", param); +console.log("MINTED", param.length); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 4ba654487c..43d52b49de 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -29,6 +29,7 @@ import { import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; +import { buildZcodeIdentityHeaders, buildZcodeTraceHeaders, isZcodePlanMeteredEndpoint } from "./zcode-identity"; import { isXaiSchemaTarget, lookupLocalJsonPointer, @@ -90,9 +91,16 @@ function openAIChatTransport(provider: OcxProviderConfig): { if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); } + // Plan-metered GLM destinations (coding-plan paths on api.z.ai / open.bigmodel.cn) are + // attributed by client identity and ZCode-identified traffic receives the increased + // usage allowance; neutral headers would meter the same plan without the bonus. + const zcodeIdentity = isZcodePlanMeteredEndpoint(provider.baseUrl) + ? { ...buildZcodeIdentityHeaders(), ...buildZcodeTraceHeaders("coding-plan") } + : {}; const headers: Record = { "Content-Type": "application/json", ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers), + ...zcodeIdentity, }; if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4aa523ee6..b165451c00 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -28,6 +28,7 @@ import { normalizeResponsesCodeMode } from "./responses-code-mode"; import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; +import { buildZcodeIdentityHeaders, buildZcodeTraceHeaders, isZcodePlanMeteredEndpoint } from "./zcode-identity"; import { isXaiSchemaTarget, normalizeXaiToolParameters, @@ -2313,7 +2314,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { const translatorBudget = incoming.translatorBudget; - const headers: Record = { "Content-Type": "application/json" }; + // Plan-metered GLM destinations (e.g. the BigModel Coding Plan Responses wire) are + // attributed by client identity; ZCode-identified traffic receives the increased + // usage allowance. Forward-mode (ChatGPT backend) is never plan-metered. + const zcodeIdentity = provider.authMode !== "forward" && isZcodePlanMeteredEndpoint(provider.baseUrl) + ? { ...buildZcodeIdentityHeaders(), ...buildZcodeTraceHeaders("coding-plan") } + : {}; + const headers: Record = { "Content-Type": "application/json", ...zcodeIdentity }; let url: string; if (provider.authMode === "forward") { diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index d8edbead92..18f7ec4c5d 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -9,6 +9,7 @@ import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; +import { createZcodeStartPlanAdapter } from "./zcode-start-plan"; import { createOpenAIChatAdapter } from "./openai-chat"; import { createOllamaNativeAdapter } from "./ollama-native"; import { createResponsesPassthroughAdapter } from "./openai-responses"; @@ -116,6 +117,10 @@ export const ADAPTER_REGISTRY = { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), }, + "zcode-start-plan": { + contractParent: "anthropic", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createZcodeStartPlanAdapter(provider), + }, qoder: { contractParent: "codebuddy", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderAdapter(provider), diff --git a/src/adapters/zcode-identity.ts b/src/adapters/zcode-identity.ts new file mode 100644 index 0000000000..b0b826ec5a --- /dev/null +++ b/src/adapters/zcode-identity.ts @@ -0,0 +1,98 @@ +/** + * ZCode client identity headers. + * + * Z.ai attributes GLM Coding Plan traffic to the client that sent it, and grants the + * ZCode harness an increased usage allowance (150%) over third-party clients. These + * builders reproduce the official client's LLM companion headers so plan-metered + * destinations are attributed correctly: + * + * - Identity: `HTTP-Referer`, `User-Agent: ZCode/` (optionally with the AI SDK + * suffix the client appends on Anthropic-wire calls), `X-ZCode-App-Version`, `X-Title`, + * `X-Release-Channel`, `X-Client-Language`/`-Timezone` (always sent, "unknown" + * fallback), `X-Platform`, `X-Os-Category`, `X-Os-Version`, and `X-ZCode-Agent: glm` + * last. No `X-Device-Mid` on LLM calls. + * - Attribution: fresh `x-request-id`/`x-zcode-trace-id` per request and + * `x-zcode-session-type: main`; the coding-plan routes also carry `x-query-id` and + * `x-session-id`, which the plan gateway route omits. + * + * Eligibility is endpoint-based: only destinations that meter a plan subscription — + * the coding paths (`/api/coding/paas/v4`, the BigModel Responses v1 route) and the + * zcode.z.ai plan gateway — qualify. Pay-as-you-go endpoints (`/api/paas/v4`) have no + * subscription quota, so identifying there would be pointless noise. + */ +import { randomUUID } from "node:crypto"; +import { arch, platform, release } from "node:os"; + +const ZCODE_APP_VERSION = process.env.ZCODE_PLAN_APP_VERSION?.trim() || "3.11.2"; +const ANTHROPIC_SDK_UA = "ai-sdk/anthropic/3.0.81"; +const ZCODE_PLAN_ORIGIN = "https://zcode.z.ai"; + +function printable(value: string | undefined): string | undefined { + const v = value?.trim(); + return v && /^[\x20-\x7e]+$/.test(v) ? v : undefined; +} + +function osCategory(p: string): string { + if (p === "darwin") return "macos"; + if (p === "win32") return "windows"; + return "linux"; +} + +function clientLanguage(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().locale || "unknown"; + } catch { + return "unknown"; + } +} + +function clientTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown"; + } catch { + return "unknown"; + } +} + +/** True for plan-metered Z.ai/Zhipu destinations (coding-plan and plan-gateway routes). */ +export function isZcodePlanMeteredEndpoint(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + const url = baseUrl.replace(/\/+$/, "").toLowerCase(); + return url === "https://zcode.z.ai/api/v1/zcode-plan" + || url === "https://zcode.z.ai/api/v1/zcode-plan/anthropic" + || url === "https://api.z.ai/api/coding/paas/v4" + || url === "https://open.bigmodel.cn/api/coding/paas/v4" + || url === "https://open.bigmodel.cn/api/v1"; +} + +/** Companion headers the official client sends on every LLM completion. */ +export function buildZcodeIdentityHeaders(opts: { userAgentSuffix?: string } = {}): Record { + const version = printable(ZCODE_APP_VERSION); + const osPlatform = printable(process.env.ZCODE_IDENTITY_PLATFORM ?? platform()) ?? ""; + const osArch = printable(process.env.ZCODE_IDENTITY_ARCH ?? arch()) ?? ""; + const osRelease = printable(process.env.ZCODE_IDENTITY_RELEASE ?? release()); + const releaseChannel = process.env.ZCODE_ENV?.trim().toLowerCase() === "test" ? "test" : "production"; + return { + "HTTP-Referer": ZCODE_PLAN_ORIGIN, + "User-Agent": `ZCode/${version ?? "unknown"}${opts.userAgentSuffix ? ` ${opts.userAgentSuffix}` : ""}`, + ...(version ? { "X-ZCode-App-Version": version } : {}), + "X-Title": "Z Code@cli", + "X-Release-Channel": releaseChannel, + "X-Client-Language": clientLanguage(), + "X-Client-Timezone": clientTimezone(), + ...(osPlatform && osArch ? { "X-Platform": `${osPlatform}-${osArch}` } : {}), + ...(osPlatform ? { "X-Os-Category": osCategory(osPlatform) } : {}), + ...(osRelease ? { "X-Os-Version": osRelease } : {}), + "X-ZCode-Agent": "glm", + }; +} + +/** Fresh per-request attribution headers; scope selects the coding-plan-only pair. */ +export function buildZcodeTraceHeaders(scope: "start-plan" | "coding-plan" = "coding-plan"): Record { + return { + "x-request-id": randomUUID(), + "x-zcode-session-type": "main", + "x-zcode-trace-id": randomUUID(), + ...(scope === "coding-plan" ? { "x-query-id": randomUUID(), "x-session-id": randomUUID() } : {}), + }; +} diff --git a/src/adapters/zcode-start-plan.ts b/src/adapters/zcode-start-plan.ts new file mode 100644 index 0000000000..940d2ef0bd --- /dev/null +++ b/src/adapters/zcode-start-plan.ts @@ -0,0 +1,212 @@ +/** + * ZCode plan gateway adapter (zcode.z.ai /api/v1/zcode-plan/anthropic). + * + * Serves the Z.ai Start Plan quota bundled with the ZCode desktop client, logged in via + * `ocx login zcode-start-plan` (OAuth CLI flow — no ZCode installation involved). The JWT + * arrives as `provider.apiKey` through the standard oauth account rotation. + * + * Wire shape (mirrors the official client's LLM calls): + * - POST {baseUrl}/v1/messages, Anthropic format, `Authorization: Bearer ` + + * `anthropic-version` only — the plan credential and V4 client-signing headers are NOT + * sent on this route (the client's `isUnsignedModelRequestPath` set exempts it). + * - Identity headers: `HTTP-Referer`, `User-Agent: ZCode/ ai-sdk/anthropic/3.0.81` + * (the AI SDK appends its identity to the UA), `X-Title`, `X-Release-Channel`, + * `X-Client-Language`/`-Timezone` (always sent, "unknown" fallback), `X-Platform`, + * `X-Os-Category`, `X-Os-Version`, and `X-ZCode-Agent: glm` last. No `X-Device-Mid`. + * - Attribution headers: fresh `x-request-id`/`x-zcode-trace-id` per request and + * `x-zcode-session-type: main`. + * + * The gateway fronts an Aliyun WAF that challenges unfamiliar clients with a captcha + * (biz code 3007 in the body, or a non-empty `x-aliyun-captcha-verify-param` response + * header). On a challenge this adapter mints a verify param through the local traceless + * solver and replays the request ONCE with the `X-Aliyun-Captcha-Verify-Param` / + * `X-Aliyun-Captcha-Verify-Region` headers; a 3012 response is the WAF's hard block and is + * surfaced as upstream_error (it decays on its own; retrying immediately deepens it). + */ +import { createAnthropicAdapter } from "./anthropic"; +import type { AdapterFetchContext, AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; +import type { OcxParsedRequest, OcxProviderConfig } from "../types"; +import { solveTraceless } from "./zcode-start-plan/captcha-solver"; +import { transformStartPlanBody, userIdFromJwt } from "./zcode-start-plan/body-transform"; +import { buildZcodeIdentityHeaders, buildZcodeTraceHeaders } from "./zcode-identity"; + +/** Public config endpoint the desktop client reads its captcha scene from. */ +const CAPTCHA_CONFIG_URL = + "https://zcode.z.ai/api/v1/client/configs?app_version=3.11.2&platform=linux-x64"; +const CAPTCHA_PARAM_HEADER = "x-aliyun-captcha-verify-param"; +const CAPTCHA_REGION_HEADER = "x-aliyun-captcha-verify-region"; +/** Magic strings of the in-body challenge, both JSON spacing styles. */ +const CHALLENGE_BODY_MARKERS = ['"code":3007', '"code": 3007'] as const; + +/** Client identity headers for the plan gateway (Anthropic-wire AI SDK suffix). */ +export const buildLlmIdentityHeaders = () => buildZcodeIdentityHeaders({ userAgentSuffix: "ai-sdk/anthropic/3.0.81" }); +/** Start-plan attribution: no x-query-id/x-session-id pair. */ +export const buildTraceHeaders = () => buildZcodeTraceHeaders("start-plan"); + +/** True when the provider targets the ZCode plan gateway. */ +export function isZcodeStartPlanEndpoint(baseUrl: string | undefined): boolean { + return !!baseUrl && /https:\/\/(zcode\.z\.ai|zcode\.chatglm\.site)\/api\/v1\/zcode-plan/.test(baseUrl); +} + +async function readCaptchaScene(): Promise<{ sceneId: string; prefix: string; region: string }> { + const res = await fetch(CAPTCHA_CONFIG_URL); + if (!res.ok) throw new Error(`captcha config fetch failed: status ${res.status}`); + const body = (await res.json()) as { + data?: { configs?: { captcha?: { enabled?: boolean; sceneId?: string; prefix?: string; region?: string } } }; + }; + const cfg = body.data?.configs?.captcha; + if (!cfg?.enabled || !cfg.sceneId || !cfg.prefix) throw new Error("captcha config unavailable"); + return { sceneId: cfg.sceneId, prefix: cfg.prefix, region: cfg.region ?? "sgp" }; +} + +async function readBodyText(response: Response): Promise { + if (/text\/event-stream/i.test(response.headers.get("content-type") ?? "")) return undefined; + try { + return await response.text(); + } catch { + return undefined; + } +} + +/** + * The gateway reports business errors (quota, auth, WAF) inside HTTP 200 JSON bodies + * (`{"code":1005,"msg":"exceed quota limit"}`) — including for streaming requests, where + * leaving them in place surfaces downstream as a silently truncated SSE stream. Map the + * known shapes onto real HTTP statuses; genuine message/SSE bodies pass through untouched. + */ +async function unwrapBizError(response: Response): Promise { + if (!response.ok || !/application\/json/i.test(response.headers.get("content-type") ?? "")) return response; + const text = await readBodyText(response); + if (!text) { + return new Response("", { status: response.status, statusText: response.statusText, headers: response.headers }); + } + let parsed: { code?: unknown; msg?: unknown }; + try { + parsed = JSON.parse(text) as { code?: unknown; msg?: unknown }; + } catch { + return new Response(text, { status: response.status, statusText: response.statusText, headers: response.headers }); + } + if (typeof parsed.code !== "number") { + return new Response(text, { status: response.status, statusText: response.statusText, headers: response.headers }); + } + const message = `zcode-start-plan: gateway biz error ${parsed.code}: ${String(parsed.msg ?? "unknown")}`; + const status = parsed.code === 1005 ? 429 : 502; + return new Response( + JSON.stringify({ error: { message, type: parsed.code === 1005 ? "rate_limit_error" : "upstream_error", code: parsed.code } }), + { status, headers: { "content-type": "application/json" } }, + ); +} + +/** Captcha challenge detector: non-2xx with the verify-param response header, or an in-body 3007. */ +export function isCaptchaChallenge(status: number, headers: Headers, bodyText: string | undefined): boolean { + if (status >= 200 && status < 300) return false; + if (headers.get(CAPTCHA_PARAM_HEADER)?.trim()) return true; + return !!bodyText && CHALLENGE_BODY_MARKERS.some(m => bodyText.includes(m)); +} + +export function createZcodeStartPlanAdapter(provider: OcxProviderConfig): ProviderAdapter { + const inner = createAnthropicAdapter(provider); + let inflightSolve: Promise<{ param: string; region: string }> | undefined; + + const solveCaptcha = async (): Promise<{ param: string; region: string }> => { + // Serialized: verify params are single-use, so concurrent solves only burn risk score. + inflightSolve ??= (async () => { + const scene = await readCaptchaScene(); + const param = await solveTraceless({ scene: scene.sceneId, region: scene.region, prefix: scene.prefix, timeoutMs: 30_000 }); + return { param, region: scene.region }; + })().finally(() => { + inflightSolve = undefined; + }); + return inflightSolve; + }; + + const isChallenge = isCaptchaChallenge; + + return { + ...inner, + name: "zcode-start-plan", + + async buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): Promise { + const built = await inner.buildRequest(parsed, incoming); + const headers: Record = {}; + for (const [name, value] of Object.entries(built.headers)) { + // The inner anthropic adapter runs in oauth mode and adds the Claude Code CLI + // fingerprint (anthropic-beta, X-App/X-Stainless-*, session id, SDK UA). The zcode + // plan gateway must see the ZCode client's identity instead. + const lower = name.toLowerCase(); + if (lower === "user-agent" || lower === "anthropic-beta" || lower === "x-app" + || lower.startsWith("x-stainless-") || lower === "x-claude-code-session-id" + || lower === "x-client-request-id") { + continue; + } + headers[name] = value; + } + const jwt = headers["Authorization"] ?? headers["authorization"]; + if (typeof jwt !== "string" || jwt.length === 0) { + throw new Error("zcode-start-plan: no JWT — run ocx login zcode-start-plan"); + } + // The gateway inspects the body: without the ZCode identity system blocks it rejects + // with biz code 3012 even when auth and captcha pass. + const model = JSON.parse(built.body as string).model as string | undefined; + const body = transformStartPlanBody(built.body as string, model, userIdFromJwt(jwt.replace(/^Bearer /, ""))); + return { + ...built, + body, + headers: { + ...headers, + ...buildLlmIdentityHeaders(), + ...buildTraceHeaders(), + }, + }; + }, + + async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { + const doFetch = (headers: Record): Promise => + fetch(request.url, { + method: request.method, + redirect: "manual", + headers, + body: request.body, + signal: ctx?.abortSignal, + }); + + let response = await doFetch(request.headers as Record); + if (!response.ok) { + const bodyText = await readBodyText(response); + if (isChallenge(response.status, response.headers, bodyText)) { + // Challenge: cancel the challenged body, mint a fresh verify param, replay ONCE. + try { + await response.body?.cancel(); + } catch { /* already drained */ } + let captcha: { param: string; region: string }; + try { + captcha = await solveCaptcha(); + } catch (err) { + return new Response( + JSON.stringify({ + error: { + message: `zcode-start-plan: gateway captcha challenge and the local solver failed: ${err instanceof Error ? err.message : String(err)}`, + type: "upstream_error", + }, + }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + response = await doFetch({ + ...(request.headers as Record), + "X-Aliyun-Captcha-Verify-Param": captcha.param, + "X-Aliyun-Captcha-Verify-Region": captcha.region, + }); + } else { + // Rebuild from the consumed text so the caller still has a readable body. + response = new Response(bodyText ?? "", { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + } + return unwrapBizError(response); + }, + }; +} diff --git a/src/adapters/zcode-start-plan/body-transform.ts b/src/adapters/zcode-start-plan/body-transform.ts new file mode 100644 index 0000000000..6ee9c20910 --- /dev/null +++ b/src/adapters/zcode-start-plan/body-transform.ts @@ -0,0 +1,137 @@ +/** + * Request-body shaping required by the zcode.z.ai start-plan gateway. + * + * The gateway inspects the POST body: without the official ZCode identity blocks at the + * head of the `system` field it rejects the request with biz code 3012 ("method not + * allowed"), regardless of auth. Three transforms mirror the official client: + * + * 1. Prepend the static ZCode system blocks (CLI prefix, agent identity, environment) + * before any caller system blocks, merging the dynamic + * "You are powered by the model named ." line into the trailing Environment + * block's text (never as a separate block). + * 2. Two-phase cache_control handling: strip stray `cache_control` from non-system + * content blocks, then mark the LAST content block of the LAST non-system message + * as ephemeral — the breakpoint the official client keeps for prompt caching. + * 3. Inject `metadata.user_id` from the plan JWT's `user_id` claim when it can be + * decoded, preserving any other metadata fields. + */ +import systemBlocksJson from "./system-blocks.json"; + +interface SystemBlock { + type: "text"; + text: string; + cache_control?: { type: "ephemeral" }; +} + +const ZCODE_SYSTEM_BLOCKS = systemBlocksJson as SystemBlock[]; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Prepend the official gateway blocks to the request's `system` field (pure). */ +export function buildStartPlanSystem(existingSystem: unknown, currentModel?: string): SystemBlock[] { + const official = ZCODE_SYSTEM_BLOCKS.map(b => ({ ...b, cache_control: b.cache_control ? { ...b.cache_control } : undefined })); + const model = currentModel?.trim(); + if (model) { + const env = official[official.length - 1]; + if (env) env.text = `${env.text}\n- You are powered by the model named ${model}.`; + } + return [...official, ...normalizeUserSystem(existingSystem)]; +} + +function normalizeUserSystem(system: unknown): SystemBlock[] { + if (typeof system === "string") { + const text = system.trim(); + return text ? [{ type: "text", text }] : []; + } + if (!Array.isArray(system)) return []; + const out: SystemBlock[] = []; + for (const item of system) { + if (typeof item === "string") { + if (item.trim()) out.push({ type: "text", text: item }); + } else if (isPlainObject(item) && item.type === "text" && typeof item.text === "string" && item.text.trim()) { + out.push({ + type: "text", + text: item.text, + ...(isPlainObject(item.cache_control) ? { cache_control: item.cache_control as { type: "ephemeral" } } : {}), + }); + } + } + return out; +} + +interface WithMessages { + system?: unknown; + messages?: unknown; +} + +/** Strip cache_control from non-system blocks, then mark the last message's last block. */ +export function applyStartPlanCacheControl(body: WithMessages): boolean { + const messages = body.messages; + if (!Array.isArray(messages)) return false; + let modified = false; + for (const msg of messages) { + if (!isPlainObject(msg) || msg.role === "system" || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (isPlainObject(block) && "cache_control" in block) { + delete block.cache_control; + modified = true; + } + } + } + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!isPlainObject(msg) || msg.role === "system") continue; + if (typeof msg.content === "string") { + msg.content = [{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } }]; + return true; + } + if (Array.isArray(msg.content) && msg.content.length > 0) { + const last = msg.content[msg.content.length - 1]; + if (isPlainObject(last) && !last.cache_control) { + last.cache_control = { type: "ephemeral" }; + return true; + } + return modified; + } + return modified; + } + return modified; +} + +/** Decode the `user_id` claim from a plan JWT without verifying (it is our own token). */ +export function userIdFromJwt(jwt: string | undefined): string | undefined { + if (!jwt) return undefined; + const part = jwt.split(".")[1]; + if (!part) return undefined; + try { + const payload = JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as { user_id?: unknown }; + return typeof payload.user_id === "string" && payload.user_id.trim() ? payload.user_id : undefined; + } catch { + return undefined; + } +} + +/** Apply all gateway body transforms in place; returns the (possibly reserialized) body. */ +export function transformStartPlanBody(bodyText: string, model: string | undefined, userId: string | undefined): string { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return bodyText; + } + if (!isPlainObject(parsed)) return bodyText; + const body = parsed as Record & WithMessages; + body.system = buildStartPlanSystem(body.system, model); + applyStartPlanCacheControl(body); + if (userId) { + const existing = body.metadata; + const base = isPlainObject(existing) ? { ...existing } : {}; + if (base.user_id !== userId) { + base.user_id = userId; + body.metadata = base; + } + } + return JSON.stringify(body); +} diff --git a/src/adapters/zcode-start-plan/captcha-solver.ts b/src/adapters/zcode-start-plan/captcha-solver.ts new file mode 100644 index 0000000000..cacd5f457e --- /dev/null +++ b/src/adapters/zcode-start-plan/captcha-solver.ts @@ -0,0 +1,2282 @@ +// @ts-nocheck +/** + * captcha-solver.ts — in-process traceless Aliyun captcha solver for the + * zcode.z.ai plan gateway. + * + * Runs the official Aliyun Captcha 2.0 SDK inside a happy-dom window (no + * browser): cookie priming of the gateway origin, deterministic fingerprint, + * CDN cache for the SDK bundles, guest-realm timer scoping, and a fail-fast + * stall detector. + * + * The fingerprint and its polyfill values MUST stay deterministic across + * solves — Aliyun's risk engine flags per-solve randomization as F001. + */ +import { GlobalWindow as Window, PropertySymbol } from "happy-dom"; +import WindowBrowserContext from "happy-dom/lib/window/WindowBrowserContext.js"; +import { ProxyAgent, setGlobalDispatcher } from "undici"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Worker } from "node:worker_threads"; + +// ── Blocking fetch for sync XHR (self-contained builds) ──────────────────── +// happy-dom implements sync XHR by spawning `process.argv[0] -e +`; + +function diskPathFor(url) { + return path.join(CDN_CACHE_DIR, crypto.createHash("sha1").update(String(url)).digest("hex")); +} + +function sniffMime(url) { + if (/\.js(\?|$)/i.test(url)) return "application/javascript"; + if (/\.css(\?|$)/i.test(url)) return "text/css"; + if (/\.png(\?|$)/i.test(url)) return "image/png"; + if (/\.(jpg|jpeg)(\?|$)/i.test(url)) return "image/jpeg"; + if (/\.json(\?|$)/i.test(url)) return "application/json"; + return "application/octet-stream"; +} + +// ── CDN cache access ──────────────────────────────────────────────────────── +function getCachedBody(url) { + const mem = _memCdnCache.get(url); + if (mem) return mem; + try { + const p = diskPathFor(url); + if (fs.existsSync(p)) { + const body = fs.readFileSync(p); + _memCdnCache.set(url, body); + return body; + } + } catch (_) {} + return null; +} + +async function fetchAndStore(url) { + try { + const res = await fetch(url, { headers: { "user-agent": fp.userAgent } }); + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > 0) { + _memCdnCache.set(url, buf); + try { + const p = diskPathFor(url); + fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); + fs.writeFileSync(p, buf); + // verify write completed (no partial file) + const stat = fs.statSync(p); + if (stat.size !== buf.length) { + process.stderr.write(`[cache-write-short] ${url} wrote ${stat.size}/${buf.length}b — rewrite\n`); + fs.writeFileSync(p, buf); + } + } catch (err) { + if (_DEBUG) process.stderr.write(`[cache-write-err] ${url}: ${err.message}\n`); + } + } + return buf; + } catch (err) { + if (_DEBUG) process.stderr.write(`[loader-fetch-err] ${url}: ${err.message}\n`); + return null; + } +} + +// ── Request header injection (every frame request: XHR, fetch, scripts) ──── +function injectRequestHeaders(request) { + const h = request.headers; + try { + h.set("sec-ch-ua", '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"'); + h.set("sec-ch-ua-mobile", "?0"); + h.set("sec-ch-ua-platform", '"Linux"'); + h.set("user-agent", fp.userAgent); + h.set("accept-language", "en-US,en;q=0.9"); + h.set("referer", "https://zcode.z.ai/"); + let origin = null; + try { + const u = new URL(request.url); + const method = String(request.method || "GET").toUpperCase(); + const crossOrigin = u.origin !== "https://zcode.z.ai"; + if (crossOrigin || (method !== "GET" && method !== "HEAD")) { + origin = "https://zcode.z.ai"; + } + } catch (_) {} + if (origin) h.set("origin", origin); + } catch (_) {} +} + +function cookieHeader(request, window, browserFrame) { + try { + const ctx = browserFrame.page.context; + const u = new URL(request.url); + if (request.credentials === "omit") return null; + const cookies = ctx.cookieContainer.getCookies(u, false); + if (cookies.length > 0) { + return cookies.map((c) => `${c.name}=${c.value}`).join("; "); + } + } catch (_) {} + return null; +} + +function storeSetCookies(res, url) { + try { + const list = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; + if (list.length) { + const cookieContainer = global.__cookieContainer; + if (cookieContainer) { + for (const raw of list) { + const u = new URL(url); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie = { + name: pair[0].trim(), + value: pair.slice(1).join("=").trim(), + url: u.origin, + domain: u.hostname, + path: "/", + }; + for (const p of parts.slice(1)) { + const kv = p.trim().split(/=(.*)/s); + const k = (kv[0] || "").toLowerCase(); + if (k === "domain" && kv[1]) cookie.domain = kv[1]; + if (k === "path" && kv[1]) cookie.path = kv[1]; + if (k === "expires") cookie.expires = new Date(kv[1]).getTime(); + if (k === "max-age") cookie.maxAge = parseInt(kv[1], 10); + if (k === "httponly") cookie.httpOnly = true; + if (k === "secure") cookie.secure = true; + if (k === "samesite") cookie.sameSite = kv[1]; + } + try { + cookieContainer.addCookies([cookie]); + } catch (_) {} + } + } + } + } catch (_) {} +} + +// ── The interceptor: replaces happy-dom's network layer completely ───────── +// All frame requests (scripts, XHR, fetch, images) funnel through here. +function makeInterceptor(bypassPeCache = false) { + const skipPeCache = (url) => bypassPeCache && /dynamicJS\/.*\/pe\.\d+\./.test(url); + return { + async beforeAsyncRequest({ request, window: w }) { + const url = request.url; + _requestLog.push({ at: Date.now(), method: request.method, url }); + injectRequestHeaders(request); + if (/\balicdn\.com/i.test(url)) { + let body = skipPeCache(url) ? null : getCachedBody(url); + if (body && /\.js(\?|$)/i.test(url)) { + try { + new Function(body.toString("utf8")); + } catch (parseErr) { + process.stderr.write(`[cache-bad-js] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); + _memCdnCache.delete(url); + try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + body = null; + } + } + // sync interceptor serves only from cache; the async interceptor + // above warms the cache on first load, so misses fall through to + // the async fetch path handled by happy-dom. + if (body) { + if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { + try { w.__lastPeUrl = url; } catch (_) {} + } + return new w.Response(Buffer.from(body), { + status: 200, + statusText: "OK", + headers: { "content-type": sniffMime(url) }, + }); + } + } + // Passthrough via global fetch (undici; honors global ProxyAgent). + try { + const init = { method: request.method, headers: {} }; + request.headers.forEach((value, key) => { + init.headers[key] = value; + }); + const bs = new URL(url); + const cookie = cookieHeader(request, w, global.__browserFrame); + if (cookie) init.headers.cookie = cookie; + let hasBody = false; + try { + if (request.body) { + const ab = await request.arrayBuffer(); + if (ab && ab.byteLength > 0) { + init.body = ab; + hasBody = true; + } + } + } catch (_) {} + const res = await fetch(url, init); + const buf = Buffer.from(await res.arrayBuffer()); + storeSetCookies(res, url); + if (_DEBUG && /captcha-open|verify\.|device\.saf|cloudauth-device|upload\./i.test(url) && buf.length && buf.length < 4096) { + try { + process.stderr.write(`[xhr-body] ${request.method} ${bs.hostname}${bs.pathname}-> ${res.status} ${buf.toString("utf8").slice(0, 1200)}\n`); + } catch (_) {} + } + const headers = {}; + const ct = res.headers.get("content-type"); + if (ct) headers["content-type"] = ct; + const logHost = bs.hostname; + if (_DEBUG) + process.stderr.write( + `[xhr] ${request.method} ${logHost}${bs.pathname} -> ${res.status} (${buf.length}b)\n`, + ); + return new w.Response(buf, { + status: res.status, + statusText: res.statusText || "", + headers, + }); + } catch (err) { + if (_DEBUG) process.stderr.write(`[xhr-err] ${url}: ${err.message}\n`); + return new w.Response("", { status: 503, statusText: "passthrough failed" }); + } + }, + beforeSyncRequest({ request, window: w }) { + const url = request.url; + _requestLog.push({ at: Date.now(), method: request.method, url, sync: true }); + injectRequestHeaders(request); + let body = null; + if (/\balicdn\.com/i.test(url)) { + body = skipPeCache(url) ? null : getCachedBody(url); + if (body && /\.js(\?|$)/i.test(url)) { + try { + new Function(body.toString("utf8")); + } catch (parseErr) { + process.stderr.write(`[cache-bad-js:sync] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); + _memCdnCache.delete(url); + try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + body = null; + } + } + // sync interceptor serves only from cache; the async interceptor + // above warms the cache on first load, so misses fall through to + // the async fetch path handled by happy-dom. + } + if (body) { + if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { + try { w.__lastPeUrl = url; } catch (_) {} + } + return { + status: 200, + statusText: "OK", + ok: true, + url, + redirected: false, + headers: new w.Headers({ "content-type": sniffMime(url) }), + body: Buffer.from(body), + [PropertySymbol.virtualServerFile]: null, + }; + } + // Non-CDN sync request: serve it blocking via a worker thread. Never + // fall through to happy-dom's own sync fetch — it spawns a child + // process with `process.argv[0] -e`, which breaks compiled binaries. + const init = { method: request.method, headers: {} as Record }; + request.headers.forEach((value, key) => { + init.headers[key] = value; + }); + const cookie = cookieHeader(request, w, global.__browserFrame); + if (cookie) init.headers.cookie = cookie; + try { + if (request.body) { + const ab = request.body; + if (ab && (ab as any).byteLength > 0) init.body = ab; + } + } catch (_) {} + const res = syncFetchBlocking(url, init as any) as any; + if (res.error) { + process.stderr.write(`[sync-xhr-err] ${url}: ${res.error}\n`); + return new w.Response("", { status: 503, statusText: "sync fetch failed" }); + } + try { + for (const raw of res.setCookie || []) { + const cookieContainer = global.__cookieContainer; + if (!cookieContainer) break; + const u = new URL(url); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie: any = { + name: pair[0].trim(), + value: pair.slice(1).join("=").trim(), + url: u.origin, + domain: u.hostname, + path: "/", + }; + for (const p of parts.slice(1)) { + const kv = p.trim().split(/=(.*)/s); + const k = (kv[0] || "").toLowerCase(); + if (k === "domain" && kv[1]) cookie.domain = kv[1]; + if (k === "path" && kv[1]) cookie.path = kv[1]; + if (k === "expires") cookie.expires = new Date(kv[1]).getTime(); + if (k === "max-age") cookie.maxAge = parseInt(kv[1], 10); + if (k === "httponly") cookie.httpOnly = true; + if (k === "secure") cookie.secure = true; + if (k === "samesite") cookie.sameSite = kv[1]; + } + try { cookieContainer.addCookies([cookie]); } catch (_) {} + } + } catch (_) {} + const hdrs: Record = {}; + for (const [k, v] of Object.entries(res.headers || {})) hdrs[k] = String(v); + // Sync interceptor contract: PLAIN OBJECT with Buffer body (happy-dom's + // SyncFetch reads `.body.toString()`); a window.Response here would + // stringify its ReadableStream body and corrupt script loading. + return { + status: res.status, + statusText: res.statusText || "", + ok: res.status >= 200 && res.status < 300, + url, + redirected: false, + headers: new w.Headers(hdrs), + body: Buffer.from(res.body), + [PropertySymbol.virtualServerFile]: null, + }; + }, + }; +} + +// ── Lexical guest scope: timer OWNERSHIP, not caller guessing ────────────── +// Under Bun, guest scripts execute in the HOST realm, so a bare `setTimeout` +// inside SDK code resolves to the host's. Host timers outlive the window: +// a stray FeiLin callback that re-arms its 2s heartbeat (feilin008.js: +// `tV = setInterval(tE, 2e3)`) after destroyDom keeps firing forever and +// eventually dereferences a torn-down global — the field-reported +// "ReferenceError: moveBy is not defined" that killed the TUI. +// +// Guest timers must therefore land on the WINDOW registry, which happy-dom +// clears in happyDOM.close(). The previous approach decided this at CALL time +// by sniffing `new Error().stack` for a CDN frame, but a stack describes the +// call chain, not ownership, and it misjudges BOTH ways: +// • false negative — guest code built via `new Function` carries no CDN +// frame, so its heartbeat escaped onto the immortal host lane; +// • false positive — host runtime code invoked beneath a guest frame was +// handed a window timer with no `.unref()`, the v4.5.2 crash shape. +// Ownership is a property of where CODE COMES FROM, so we bind it lexically. +// Each guest script is evaluated inside `with (scope) { … }`, where `scope` +// carries this window's timer methods. Identifier resolution is settled by the +// scope chain at parse time; no stack is ever consulted, so neither misjudgement +// is expressible. `with` (not an IIFE wrapper) because guest top-level `var` and +// `function` declarations must keep escaping to the global object — an IIFE +// swallows them and `initAliyunCaptcha` never appears (measured: every solve +// timed out). The FeiLin/pe bundles have no top-level "use strict" (their +// `"use strict"` directives sit inside module functions, which is fine), so +// `with` parses; a script that did carry one would throw at parse time and take +// the unwrapped fallback path in installEvalInstrumentation. +const GUEST_TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]; + +// Guest scopes are keyed per window so concurrent solves never share timers: +// the wrapper reads `globalThis[GUEST_SCOPE_ROOT][id]` at RUN time, and each +// window gets its own id. A plain object (not a Map) because the wrapper text +// indexes it directly from guest source. +const GUEST_SCOPE_ROOT = "__capGuestScopes"; +let _guestScopeSeq = 0; + +/** + * Create this window's guest scope and return the id the wrapper embeds. + * + * Holds the window's own timer methods (bound to the window), a `Function` + * stand-in (see makeScopedFunction), and the window's console. The console + * matters as much as the timers: the FeiLin SDK probes for devtools by + * printing invisible `%c%d` format strings across every console method, ~1/sec + * while solving. Resolved lexically it lands on the window's silent console; + * left to the host it garbles the TUI's alternate screen with bare "NaN" rows. + */ +function installGuestScope(w) { + const root = (globalThis[GUEST_SCOPE_ROOT] ??= Object.create(null)); + const id = `w${++_guestScopeSeq}`; + const scope = Object.create(null); + for (const name of GUEST_TIMER_PROPS) { + const fn = w[name]; + scope[name] = typeof fn === "function" ? fn.bind(w) : fn; + } + scope.Function = makeScopedFunction(w); + if (!_DEBUG && w.console) scope.console = w.console; + root[id] = scope; + w.__capScopeId = id; + return id; +} + +/** Drop the scope when the window dies, so it cannot pin a closed window. */ +function removeGuestScope(w) { + try { + const id = w && w.__capScopeId; + const root = globalThis[GUEST_SCOPE_ROOT]; + if (id && root) delete root[id]; + } catch {} +} + +/** + * A `Function` stand-in for guest scope. `new Function(body)` compiles in the + * GLOBAL scope, so a generated function would see the host timers again and + * re-open the escape hatch (measured: its heartbeat outlived window close). + * This variant re-wraps the generated body in the same `with` scope, so code + * the pe bytecode VM generates at runtime inherits the window's timers too. + * `eval` needs no equivalent: it inherits the caller's scope chain already. + */ +function makeScopedFunction(w) { + const Scoped = function (...args) { + const body = args.length ? String(args[args.length - 1]) : ""; + const params = args.slice(0, -1).map(String).join(","); + const id = w.__capScopeId; + const source = + `return function(${params}){with(globalThis.${GUEST_SCOPE_ROOT}[${JSON.stringify(id)}]){\n${body}\n}}`; + return Function(source)(); + }; + // Guest fingerprint code sweeps name/toString over platform builtins. + Scoped.prototype = Function.prototype; + try { + Object.defineProperty(Scoped, "name", { value: "Function", configurable: true }); + Object.defineProperty(Scoped, "toString", { + value: () => "function Function() { [native code] }", + configurable: true, + writable: true, + }); + } catch {} + return Scoped; +} + +/** + * Wrap guest source so bare timer identifiers resolve to `w`'s registry. + * + * A bare `with (…) { … }` statement, not a function wrapper, and that choice + * carries both of the properties this needs: + * + * - **Top-level declarations keep escaping.** `with` introduces an object + * environment, not a variable one, so guest `var`/`function` declarations + * still land on the global object. A function wrapper swallows them and + * `initAliyunCaptcha` never appears — every solve then timed out waiting + * for it (measured: ok=0 fail=3). + * + * - **The completion value still flows out.** happy-dom's JavaScriptCompiler + * hands `evaluateScript` a `(function anonymous($happy_dom){…})` expression + * and calls whatever comes back. `eval` yields a statement's completion + * value, and a block completes with its last expression statement, so the + * compiler's function expression is returned through the `with` unchanged. + * (Declarations produce no completion value, so a script ending in one is + * also fine — the preceding expression's value stands.) + */ +function wrapGuestSource(code, filename, scopeId) { + const sourceUrl = filename && /^https?:/.test(String(filename)) ? `\n//# sourceURL=${filename}` : ""; + const scopeRef = `globalThis.${GUEST_SCOPE_ROOT}[${JSON.stringify(scopeId)}]`; + // The leading newline keeps guest line numbers aligned with the CDN + // original; the trailing one guards a source ending in a line comment. + return `with(${scopeRef}){\n${code}\n}${sourceUrl}`; +} + +// ── Guest scope application on happy-dom's eval funnel (host side) ───────── +// Wraps happy-dom's VM eval funnel (window[PropertySymbol.evaluateScript]). +// Every script tag / compiled module / dynamic chunk that happy-dom parses +// passes through here, so guest source can be bound to this window's lexical +// guest scope before evaluation. Errors propagate unchanged. +function installEvalInstrumentation(w) { + const sym = PropertySymbol && PropertySymbol.evaluateScript; + if (!sym || typeof w[sym] !== "function") { + process.stderr.write("[instr] no evaluateScript symbol, host hook skipped\n"); + return; + } + const orig = w[sym]; + w[sym] = function (code, options) { + const scopeId = w.__capScopeId; + // Guest scripts run inside this window's `with` scope (wrapGuestSource): + // this funnel is the single entry point for every script tag, compiled + // module and dynamic pe/FeiLin chunk, so wrapping here covers them all. + // Our own GUEST_EVAL_PATCH goes through w.eval() and is unaffected. + if (scopeId) { + try { + return orig.call(this, wrapGuestSource(String(code ?? ""), options && options.filename, scopeId), options); + } catch (scopeErr) { + // Only a wrapper-induced parse failure (e.g. a top-level "use + // strict" making `with` illegal) falls back — a genuine error from + // the guest body must propagate to the caller unchanged. + if (!(scopeErr instanceof SyntaxError)) throw scopeErr; + process.stderr.write( + `[instr] guest scope rejected (${scopeErr.message.slice(0, 80)}), evaluating unwrapped\n`, + ); + } + } + return orig.call(this, code, options); + }; +} + +// ── Mask JS-implemented platform APIs as native (FeiLin toString sweep) ───── +function installNativeToString(w) { + const realToString = Function.prototype.toString; + const nativeRe = /\[native code\]/; + const mask = (fn) => { + if (typeof fn !== "function") return; + try { + if (nativeRe.test(realToString.call(fn))) return; + const name = fn.name || ""; + const nativeStr = `function ${name}() { [native code] }`; + Object.defineProperty(fn, "toString", { + value: () => nativeStr, + configurable: true, + writable: true, + }); + } catch (_) {} + }; + const seen = new w.Set(); + const maskObj = (obj, depth) => { + if ( + !obj || + (typeof obj !== "object" && typeof obj !== "function") || + depth > 5 + ) + return; + // Skip host-realm objects (under Bun, installGlobalWindowAlias exposes + // Bun internals via window getters; sweeping them crashes on native + // internal-field slots). happy-dom objects live in the window realm. + try { + if (obj.constructor && obj.constructor.prototype !== Object.prototype) { + const ctorName = obj.constructor.name; + if (/^(WriteStream|ReadStream|Socket|Process|Timeout|Immediate)$/.test(ctorName)) return; + } + } catch (_) {} + if (seen.has(obj)) return; + try { + seen.add(obj); + } catch (_) { + return; + } + let names = []; + try { + names = Object.getOwnPropertyNames(obj); + } catch (_) { + return; + } + for (const name of names) { + if (name === "toString" || name === "constructor") continue; + let desc; + try { + desc = Object.getOwnPropertyDescriptor(obj, name); + } catch (_) { + continue; + } + if (!desc) continue; + if (typeof desc.value === "function") { + mask(desc.value); + } else if (typeof desc.get === "function") { + mask(desc.get); + try { + const v = desc.get.call(obj); + if (typeof v === "function") mask(v); + // Probing a getter can hand back a promise that is already rejected + // (WHATWG stream `closed`/`ready` reject when the receiver is the + // prototype, not an instance). Nobody awaits these, so without a + // sink each probe surfaced as an unhandledRejection during every + // solve — noise that buried real diagnostics. + else if (v && typeof v.then === "function") v.catch(() => {}); + } catch {} + } + if (depth < 3) { + try { + const v = desc.value; + if (v && (typeof v === "function" || typeof v === "object")) + maskObj(v, depth + 1); + } catch (_) {} + } + } + }; + const targets = [ + w, + w.navigator, + w.document, + w.Document && w.Document.prototype, + w.Element && w.Element.prototype, + w.HTMLElement && w.HTMLElement.prototype, + w.Node && w.Node.prototype, + w.EventTarget && w.EventTarget.prototype, + w.HTMLCanvasElement && w.HTMLCanvasElement.prototype, + w.XMLHttpRequest && w.XMLHttpRequest.prototype, + w.Event && w.Event.prototype, + w.Window && w.Window.prototype, + ].filter(Boolean); + for (const t of targets) { + try { + maskObj(t, 0); + } catch (_) {} + } +} + +// ── Guest-context patches (run via window.eval inside the VM realm) ───────── +const GUEST_EVAL_PATCH = ` +(function() { + try { + Object.defineProperty(Event.prototype, "isTrusted", { + get() { return true; }, + configurable: true + }); + } catch (e) {} + try { + if (window.HTMLDocument) { + Object.defineProperty(window.HTMLDocument, "name", { value: "HTMLDocument", configurable: true }); + Object.defineProperty(window.HTMLDocument.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); + } + } catch (e) {} + try { + Object.defineProperty(window.Document.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); + } catch (e) {} + // Guest errors are RECORDED, not printed: the Aliyun/FeiLin SDKs throw + // benign uncaught TypeErrors inside happy-dom on every solve (imperfect DOM + // emulation) while the solve still succeeds — printing them flooded the + // console with [WINDOW-ERROR] spam. They land in window.__capErrs (capped, + // deduped) which solveTraceless surfaces only when a solve FAILS. + // CAPTCHA_DEBUG=1 streams them live again. + var __capDebug = ${_DEBUG ? "true" : "false"}; + function __capRecord(kind, msg, stack) { + try { + var m = String(msg || "?"); + var s = String(stack || "").split("\\n").slice(0, 2).join(" | "); + if (!window.__capErrs) window.__capErrs = []; + var last = window.__capErrs[window.__capErrs.length - 1]; + if (last && last.k === kind && last.m === m) { + last.n = (last.n || 1) + 1; + } else { + window.__capErrs.push({ k: kind, m: m, s: s, n: 1 }); + if (window.__capErrs.length > 8) window.__capErrs.shift(); + } + if (__capDebug) console.error("[" + kind + "]", m, s); + } catch (e2) {} + } + try { + window.addEventListener("unhandledrejection", function(e) { + var r = e && e.reason; + __capRecord("UH-REASON", (r && r.message) || typeof r, r && r.stack); + }); + } catch (e) {} + try { + window.addEventListener("error", function(e) { + __capRecord("WINDOW-ERROR", e && e.message, e && e.error && e.error.stack); + }); + } catch (e) {} + // Pass-through eval/Function wrappers kept from the removed parse-fail + // dump instrumentation (name/prototype masking preserved). + try { + var _origEval2 = window.eval; + if (_origEval2) { + window.eval = function(code) { + return _origEval2.call(window, code); + }; + } + } catch (e) {} + try { + var _of = window.Function; + if (_of) { + var _WF = function() { + return _of.apply(this, Array.prototype.slice.call(arguments)); + }; + _WF.prototype = _of.prototype; + try { Object.defineProperty(_WF, "name", { value: "Function", configurable: true }); } catch (e) {} + window.Function = _WF; + } + } catch (e) {} +})(); +`; + +// ── Browser-ish polyfills (ported from solve-core applyPolyfills) ─────────── +function applyPolyfills(w) { + // Element constructor shortcuts every real browser exposes. The FeiLin + // fingerprint SDK references `Option` as a bare identifier; missing it + // throws inside its probe chain and degrades the fingerprint. + if (typeof w.Option !== "function") { + w.Option = class Option extends w.HTMLOptionElement { + constructor(text, value, defaultSelected, selected) { + super(); + if (text !== undefined) { + const el = w.document.createElement("option"); + el.text = text; + if (value !== undefined) el.value = value; + if (defaultSelected) el.defaultSelected = true; + if (selected) el.selected = true; + return el; + } + } + }; + } + if (typeof w.Video !== "function" && w.HTMLVideoElement) { + w.Video = class Video extends w.HTMLVideoElement { + constructor() { return w.document.createElement("video"); } + }; + } + + // happy-dom lacks alert/prompt/confirm/open/close (same stubs as + // solve-shim.js line ~559-561) + if (typeof w.alert !== "function") w.alert = () => {}; + if (typeof w.prompt !== "function") w.prompt = () => null; + if (typeof w.confirm !== "function") w.confirm = () => false; + if (typeof w.open !== "function") w.open = () => null; + if (typeof w.close !== "function") w.close = () => {}; + try { Object.defineProperty(w, "alert", { value: w.alert, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "prompt", { value: w.prompt, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "confirm", { value: w.confirm, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "open", { value: w.open, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "close", { value: w.close, configurable: true, writable: true }); } catch (_) {} + + // happy-dom lacks browser globals that FeiLin / the pe risk engine probe. + // A missing one throws ReferenceError inside the VM machine → breaks the + // collection chain. Ported from solve-shim.js's stub list. + const extraGlobals = { + print: () => {}, + stop: () => {}, + moveTo: () => {}, + moveBy: () => {}, + showModalDialog: () => null, + find: () => false, + }; + for (const [k, v] of Object.entries(extraGlobals)) { + try { Object.defineProperty(w, k, { value: v, configurable: true, writable: true }); } catch (_) {} + } + // happy-dom's own open()/close() are destructive (close() tears the window + // down); the risk engine probes them → neutralize. + try { Object.defineProperty(w, "open", { value: () => null, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "close", { value: () => {}, configurable: true, writable: true }); } catch (_) {} + + if (!w.Option) { + w.Option = class { + constructor(text, value, defaultSelected, selected) { + this.text = text ?? ""; + this.value = value ?? ""; + this.selected = selected ?? defaultSelected ?? false; + this.defaultSelected = !!defaultSelected; + this.disabled = false; + this.label = this.text; + this.index = 0; + } + }; + } + + if (!w.EventSource) { + w.EventSource = class { + constructor() { + this.readyState = 2; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + } + close() { + this.readyState = 2; + } + addEventListener() {} + removeEventListener() {} + }; + } + + if (!w.Beacon) w.Beacon = class {}; + + if (!w.RTCPeerConnection) { + w.RTCPeerConnection = class { + constructor() {} + createDataChannel() { return {}; } + close() {} + createOffer() { return Promise.resolve({}); } + setLocalDescription() { return Promise.resolve(); } + addEventListener() {} + removeEventListener() {} + }; + } + + if (!w.MessageChannel) { + w.MessageChannel = class { + constructor() { + this.port1 = { onmessage: null, postMessage() {}, start() {}, close() {}, addEventListener() {}, removeEventListener() {} }; + this.port2 = { onmessage: null, postMessage() {}, start() {}, close() {}, addEventListener() {}, removeEventListener() {} }; + } + }; + } + + w.IntersectionObserver = + w.IntersectionObserver || + class { + constructor(cb) { + this.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + w.ResizeObserver = + w.ResizeObserver || + class { + observe() {} + unobserve() {} + disconnect() {} + }; + + w.DeviceOrientationEvent = + w.DeviceOrientationEvent || + class extends w.Event { + constructor(type, opts) { + super(type, opts); + } + alpha = null; + beta = null; + gamma = null; + absolute = false; + }; + + w.DeviceMotionEvent = + w.DeviceMotionEvent || + class extends w.Event { + constructor(type, opts) { + super(type, opts); + } + acceleration = null; + accelerationIncludingGravity = null; + rotationRate = null; + interval = 16; + }; + + // Window-registry timers explicitly: these callbacks only touch the window + // and must die with it (they'd otherwise survive destroyDom via the dual + // dispatcher's host lane). + w.requestIdleCallback = w.requestIdleCallback || ((cb) => w.setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1)); + w.cancelIdleCallback = w.cancelIdleCallback || ((id) => w.clearTimeout(id)); + + w.matchMedia = + w.matchMedia || + (() => ({ + matches: false, + media: "", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + })); + + if (!w.visualViewport) { + const VisualViewport = function () {}; + VisualViewport.prototype = { + width: fp.screen.w - 16, + height: fp.screen.h - 120, + scale: 1, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + onresize: null, + onscroll: null, + onscrollend: null, + }; + w.VisualViewport = VisualViewport; + w.visualViewport = Object.create(w.VisualViewport.prototype); + } + + if (!w.indexedDB) { + const IDBFactory = function () {}; + IDBFactory.prototype = { + open: () => ({ onupgradeneeded: null, onsuccess: null, onerror: null }), + deleteDatabase: () => ({}), + databases: () => Promise.resolve([]), + }; + w.IDBFactory = IDBFactory; + w.indexedDB = Object.create(w.IDBFactory.prototype); + } + + if (!w.speechSynthesis) { + const SpeechSynthesis = function () {}; + SpeechSynthesis.prototype = { + speak() {}, + cancel() {}, + pause() {}, + resume() {}, + getVoices: () => [], + }; + w.SpeechSynthesis = SpeechSynthesis; + w.speechSynthesis = Object.create(w.SpeechSynthesis.prototype); + w.SpeechSynthesisUtterance = function () {}; + } + + w.Worker = + w.Worker || + class { + postMessage() {} + terminate() {} + addEventListener() {} + removeEventListener() {} + }; + + w.Notification = + w.Notification || + class { + static permission = "default"; + static requestPermission() { + return Promise.resolve("default"); + } + close() {} + }; + + // Canvas / WebGL + const proto = w.HTMLCanvasElement.prototype; + const nativeGetContext = typeof proto.getContext === "function" ? proto.getContext : null; + proto.getContext = function (type, ...rest) { + if (/webgl/i.test(type)) { + return makeWebGLMock(this); + } + if (nativeGetContext) { + try { + const ctx = nativeGetContext.call(this, type, ...rest); + if (ctx) return ctx; + } catch (_) {} + } + return make2DStub(this); + }; + + function makeWebGLMock(canvas) { + return { + canvas, + getParameter(p) { + if (p === 7936) return "WebKit"; + if (p === 7937) return "WebKit WebGL"; + if (p === 7938) return "WebGL 1.0 (OpenGL ES 2.0 Chromium)"; + if (p === 35724) return "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"; + if (p === 0x9245) return fp.webglUnmaskedVendor; + if (p === 0x9246) return fp.webglUnmaskedRenderer; + return "Intel Inc."; + }, + getExtension(name) { + if (name === "WEBGL_debug_renderer_info") { + return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }; + } + return null; + }, + getSupportedExtensions() { + return [ + "ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float", + "EXT_disjoint_timer_query", "EXT_float_blend", "EXT_frag_depth", + "EXT_shader_texture_lod", "EXT_texture_compression_bptc", + "EXT_texture_compression_rgtc", "EXT_texture_filter_anisotropic", + "EXT_sRGB", "KHR_parallel_shader_compile", "OES_element_index_uint", + "OES_fbo_render_mipmap", "OES_standard_derivatives", + "OES_texture_float", "OES_texture_float_linear", + "OES_texture_half_float", "OES_texture_half_float_linear", + "OES_vertex_array_object", "WEBGL_color_buffer_float", + "WEBGL_compressed_texture_astc", "WEBGL_compressed_texture_etc", + "WEBGL_compressed_texture_etc1", "WEBGL_compressed_texture_s3tc", + "WEBGL_compressed_texture_s3tc_srgb", "WEBGL_debug_renderer_info", + "WEBGL_debug_shaders", "WEBGL_depth_texture", "WEBGL_draw_buffers", + "WEBGL_lose_context", "WEBGL_multi_draw", + ]; + }, + getContextAttributes() { + return { + alpha: true, antialias: true, depth: true, + failIfMajorPerformanceCaveat: false, powerPreference: "default", + premultipliedAlpha: true, preserveDrawingBuffer: false, + stencil: false, desynchronized: false, + }; + }, + getShaderPrecisionFormat() { + return { precision: 23, rangeMin: 127, rangeMax: 127 }; + }, + }; + } + + function make2DStub(canvas) { + return { + canvas, + fillRect() {}, + clearRect() {}, + getImageData: (_x, _y, w2 = 1, h2 = 1) => new w.ImageData(w2, h2), + putImageData() {}, + createImageData: (w2 = 1, h2 = 1) => new w.ImageData(w2, h2), + setTransform() {}, + transform() {}, + drawImage() {}, + save() {}, + restore() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + bezierCurveTo() {}, + quadraticCurveTo() {}, + closePath() {}, + clip() {}, + stroke() {}, + fill() {}, + arc() {}, + rect() {}, + ellipse() {}, + translate() {}, + scale() {}, + rotate() {}, + fillText() {}, + strokeText() {}, + measureText: (t) => ({ width: String(t).length * 8 }), + createLinearGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop() {} }), + createPattern: () => ({}), + isPointInPath: () => false, + font: "10px sans-serif", + textBaseline: "alphabetic", + textAlign: "start", + fillStyle: "#000", + strokeStyle: "#000", + globalAlpha: 1, + lineWidth: 1, + shadowBlur: 0, + shadowColor: "", + }; + } + + const nativeToDataURL = typeof proto.toDataURL === "function" ? proto.toDataURL : null; + proto.toDataURL = function (...a) { + try { + if (nativeToDataURL) return nativeToDataURL.apply(this, a); + } catch (_) {} + return fp.canvasImage; + }; + if (typeof proto.toBlob !== "function") { + proto.toBlob = (cb) => cb && cb(new w.Blob()); + } + + w.OffscreenCanvas = + w.OffscreenCanvas || + class { + constructor(width, height) { + this.width = width; + this.height = height; + } + getContext() { + return proto.getContext.call(this); + } + }; + + const audioMock = class { + constructor() { + this.sampleRate = 44100; + this.currentTime = 0; + this.state = "suspended"; + } + createOscillator() { + return { + type: "sine", + frequency: { value: 440, setValueAtTime() {} }, + connect() {}, + start() {}, + stop() {}, + }; + } + createDynamicsCompressor() { + return { + threshold: { value: -24, setValueAtTime() {} }, + knee: { value: 30, setValueAtTime() {} }, + ratio: { value: 12, setValueAtTime() {} }, + attack: { value: 0.003, setValueAtTime() {} }, + release: { value: 0.25, setValueAtTime() {} }, + connect() {}, + }; + } + createAnalyser() { + return { + fftSize: 2048, + frequencyBinCount: 1024, + getByteFrequencyData() {}, + getByteTimeDomainData() {}, + connect() {}, + }; + } + createGain() { + return { gain: { value: 1 }, connect() {} }; + } + destination = {}; + resume() { + this.state = "running"; + return Promise.resolve(); + } + close() { + this.state = "closed"; + return Promise.resolve(); + } + }; + w.AudioContext = w.AudioContext || audioMock; + w.OfflineAudioContext = + w.OfflineAudioContext || + class extends audioMock { + constructor(_channels, length, sampleRate) { + super(); + this.length = length; + this.sampleRate = sampleRate; + } + startRendering() { + const len = this.length || 44100; + const sr = this.sampleRate || 44100; + const buf = new Float32Array(len); + for (let i = 0; i < len; i += 1) { + const t = i / sr; + buf[i] = + Math.sin(2 * Math.PI * 1000 * t) * Math.exp(-t * 1.2) * 0.6 + + Math.sin(2 * Math.PI * 3000 * t) * Math.exp(-t * 1.5) * 0.25 + + Math.sin(2 * Math.PI * 5000 * t) * Math.exp(-t * 2.0) * 0.12; + } + return Promise.resolve({ + numberOfChannels: 1, + length: len, + sampleRate: sr, + getChannelData: () => buf, + }); + } + }; + + w.requestAnimationFrame = w.requestAnimationFrame || ((cb) => w.setTimeout(() => cb(Date.now()), 16)); + w.cancelAnimationFrame = w.cancelAnimationFrame || ((id) => w.clearTimeout(id)); + + try { + Object.defineProperty(w.document, "hidden", { value: false, configurable: true }); + Object.defineProperty(w.document, "visibilityState", { + value: "visible", + configurable: true, + }); + } catch (_) {} + + if (!w.document.fonts) { + w.document.fonts = { + ready: Promise.resolve(), + check: () => true, + addEventListener() {}, + removeEventListener() {}, + }; + } + + if (!w.chrome) { + w.chrome = { + app: { + isInstalled: false, + InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, + RunningState: { CANNOT_RUN: "cannot_run", CAN_RUN: "can_run", RUNNING: "running" }, + getDetails() { return null; }, + getIsInstalled() { return false; }, + installState(cb) { if (cb) cb("not_installed"); }, + runningState(cb) { if (cb) cb("cannot_run"); }, + }, + csi() { + const now = Date.now(); + return { startE: now - 100, onloadT: now, pageT: 100, tran: 15 }; + }, + loadTimes() { + const now = Date.now() / 1000; + return { + requestTime: now - 0.1, startLoadTime: now - 0.1, + commitLoadTime: now - 0.05, finishDocumentLoadTime: now, + finishLoadTime: now, firstPaintTime: now - 0.02, + firstPaintAfterLoadTime: 0, navigationType: "Other", + wasFetchedViaSpdy: true, wasNpnNegotiated: true, + npnNegotiatedProtocol: "h2", wasAlternateProtocolAvailable: false, + connectionInfo: "h2", + }; + }, + }; + } + + // navigator patch + const nav = w.navigator; + const plugins = createNavigatorPlugins(w); + const navPatch = { + userAgent: fp.userAgent, + platform: fp.platform, + language: "en-US", + languages: ["en-US", "en"], + vendor: "Google Inc.", + webdriver: false, + hardwareConcurrency: 12, + deviceMemory: 8, + maxTouchPoints: 0, + cookieEnabled: true, + plugins: plugins.plugins, + mimeTypes: plugins.mimeTypes, + appVersion: fp.userAgent.replace(/^Mozilla\//, ""), + appName: "Netscape", + appCodeName: "Mozilla", + product: "Gecko", + productSub: "20030107", + vendorSub: "", + oscpu: undefined, + doNotTrack: null, + sendBeacon: (url, data) => { + try { + const xhr = new w.XMLHttpRequest(); + xhr.open("POST", url, true); + xhr.send(data); + return true; + } catch (_) { + return false; + } + }, + }; + for (const [k, v] of Object.entries(navPatch)) { + try { + Object.defineProperty(nav, k, { value: v, configurable: true }); + } catch (_) {} + } + + // polyfill navigator sub-objects that happy-dom lacks + const makeNS = (protoObj) => { + const C = new w.Function(); + C.prototype = protoObj; + return new C(); + }; + + if (!nav.connection) { + const NetInfo = () => {}; + NetInfo.prototype = { onchange: null, effectiveType: "4g", rtt: 50, downlink: 10, saveData: false }; + w.NetworkInformation = NetInfo; + try { + Object.defineProperty(nav, "connection", { value: makeNS(NetInfo.prototype), configurable: true }); + } catch (_) {} + } + if (!nav.userAgentData) { + const UAData = function () {}; + UAData.prototype = { + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + getHighEntropyValues: () => + Promise.resolve({ + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + platformVersion: "6.5.0", + architecture: "x86", + model: "", + uaFullVersion: fp.uaFull, + fullVersionList: [ + { brand: "Chromium", version: fp.uaFull }, + { brand: "Not)A;Brand", version: "24.0.0.0" }, + ], + }), + }; + try { + Object.defineProperty(nav, "userAgentData", { value: makeNS(UAData.prototype), configurable: true }); + } catch (_) {} + } + if (!w.Permissions) { + const Perms = () => {}; + Perms.prototype = { + query: (param) => + Promise.resolve({ state: param.name === "notifications" ? "prompt" : "granted", onchange: null }), + }; + w.Permissions = Perms; + } + try { + if (!nav.permissions) Object.defineProperty(nav, "permissions", { value: makeNS(w.Permissions.prototype), configurable: true }); + } catch (_) {} + try { + if (!nav.clipboard) + Object.defineProperty(nav, "clipboard", { + value: makeNS({ readText: () => Promise.resolve(""), writeText: () => Promise.resolve() }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.geolocation) + Object.defineProperty(nav, "geolocation", { + value: makeNS({ + getCurrentPosition: (s) => s && s({ coords: { latitude: 0, longitude: 0, accuracy: 1 } }), + watchPosition: () => 1, + clearWatch: () => {}, + }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.credentials) + Object.defineProperty(nav, "credentials", { + value: makeNS({ get: () => Promise.resolve(null), create: () => Promise.resolve(null), store: () => Promise.resolve(), preventSilentAccess: () => Promise.resolve() }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.storage) + Object.defineProperty(nav, "storage", { + value: makeNS({ estimate: () => Promise.resolve({ quota: 1e8, usage: 0 }), persisted: () => Promise.resolve(false), persist: () => Promise.resolve(false) }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.usb) + Object.defineProperty(nav, "usb", { + value: makeNS({ getDevices: () => Promise.resolve([]), requestDevice: () => Promise.reject(new Error("no devices")) }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.mediaDevices) + Object.defineProperty(nav, "mediaDevices", { + value: makeNS({ enumerateDevices: () => Promise.resolve([]), getUserMedia: () => Promise.reject(new Error("NotAllowedError")) }), + configurable: true, + }); + } catch (_) {} + + // screen + const screenPatch = { + width: fp.screen.w, + height: fp.screen.h, + availWidth: fp.screen.w, + availHeight: fp.screen.ah, + availLeft: 0, + availTop: 0, + colorDepth: 24, + pixelDepth: 24, + orientation: { angle: 0, type: "landscape-primary", onchange: null }, + }; + for (const [k, v] of Object.entries(screenPatch)) { + try { + Object.defineProperty(w.screen, k, { get: () => v, configurable: true }); + } catch (_) {} + } + + w.outerWidth = fp.screen.w; + w.outerHeight = fp.screen.h - 40; + w.innerWidth = fp.screen.w - 16; + w.innerHeight = fp.screen.h - 120; + w.devicePixelRatio = 1; +} + +function createNavigatorPlugins(w) { + const indexed = [ + { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" }, + { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", description: "" }, + { name: "Chromium PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", description: "" }, + ]; + const plugins = w.PluginArray ? Object.create(w.PluginArray.prototype) : {}; + const mockIndexed = []; + for (let i = 0; i < indexed.length; i++) { + const p = Object.create((w.Plugin && w.Plugin.prototype) || Object.prototype); + Object.defineProperty(p, "name", { value: indexed[i].name, configurable: true, enumerable: true }); + Object.defineProperty(p, "filename", { value: indexed[i].filename, configurable: true, enumerable: true }); + Object.defineProperty(p, "description", { value: indexed[i].description, configurable: true, enumerable: true }); + Object.defineProperty(p, "length", { value: 1, configurable: true, enumerable: true }); + Object.defineProperty(p, "0", { value: p, configurable: true, enumerable: true }); + p.item = () => p; + p.namedItem = () => p; + plugins[i] = p; + mockIndexed.push(p); + } + Object.defineProperty(plugins, "length", { value: indexed.length, configurable: true, enumerable: true }); + plugins.item = (i) => plugins[i] ?? null; + plugins.namedItem = (name) => mockIndexed.find((p) => p.name === name) ?? null; + plugins.refresh = () => {}; + const mimeTypes = + w.MimeTypeArray ? Object.create(w.MimeTypeArray.prototype) : {}; + Object.defineProperty(mimeTypes, "length", { value: 0, configurable: true, enumerable: true }); + mimeTypes.item = () => null; + mimeTypes.namedItem = () => null; + return { plugins, mimeTypes }; +} + +function safeJson(x) { + try { + if (x instanceof Error) return `Error: ${x.message}\n${(x.stack || "").slice(0, 1500)}`; + const s = JSON.stringify(x); + return s !== undefined && s.length < 3000 ? s : String(x); + } catch (_) { + return String(x); + } +} + +// ── Behavioral priming (FeiLin human-motion buffer) ──────────────────────── +function simulateBehavior(w, durationMs = 600) { + const { document, MouseEvent, KeyboardEvent, UIEvent } = w; + if (!document || !MouseEvent) return; + const fire = (type, ctor, opts) => { + try { + const Ctor = ctor || UIEvent; + const ev = new Ctor(type, { bubbles: true, cancelable: true, view: w, ...opts }); + document.dispatchEvent(ev); + if (document.body) document.body.dispatchEvent(ev); + } catch (_) {} + }; + let x = 140 + Math.random() * 30; + let y = 110 + Math.random() * 20; + const targetX = 540 + Math.random() * 40; + const targetY = 380 + Math.random() * 30; + const steps = 22; + let i = 0; + const start = Date.now(); + const moveStep = () => { + if (i > steps) return; + x += (targetX - x) * 0.16 + (Math.random() - 0.5) * 5; + y += (targetY - y) * 0.16 + (Math.random() - 0.5) * 4; + fire("mousemove", MouseEvent, { + screenX: Math.round(x), + screenY: Math.round(y), + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + buttons: 1, + }); + i += 1; + const done = Date.now() - start >= durationMs; + if (i <= steps && !done) { + // Window-registry timer: the drag chain only touches the window and + // must die with it, not ride the dual dispatcher's host lane. + w.setTimeout(moveStep, 26 + Math.floor(Math.random() * 32)); + } else { + fire("mousedown", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 1 }); + fire("mouseup", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 0 }); + fire("click", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0 }); + try { + fire("keyup", KeyboardEvent, { key: "a", code: "KeyA", keyCode: 65, which: 65 }); + } catch (_) {} + } + }; + moveStep(); +} + +function waitFor(cond, timeoutMs = 15_000, intervalMs = 40) { + return new Promise((res, rej) => { + const started = Date.now(); + const timer = setInterval(() => { + let ok = false; + try { + ok = cond(); + } catch (_) {} + if (ok) { + clearInterval(timer); + res(); + } else if (Date.now() - started > timeoutMs) { + clearInterval(timer); + rej(new Error("timeout")); + } + }, intervalMs); + }); +} + +// ── createDom ────────────────────────────────────────────────────────────── +async function createDom(region, prefix) { + let cookies = []; + const now = Date.now(); + if (_cookieCache.ts > 0 && now - _cookieCache.ts < COOKIE_CACHE_TTL_MS) { + cookies = _cookieCache.cookies; + } else { + try { + const res = await fetch("https://zcode.z.ai/", { + headers: { + "User-Agent": fp.userAgent, + "sec-ch-ua": '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "Accept-Language": "en-US,en;q=0.9", + }, + }); + cookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; + _cookieCache = { cookies, ts: Date.now() }; + } catch (_) {} + } + + const interceptor = makeInterceptor(_bypassPeCacheOnce); + _bypassPeCacheOnce = false; + // Registered once per process — adding it inside createDom leaked a new + // EventEmitter listener per solve (MaxListenersExceededWarning + growth). + if (!process.__capUnhandledRejectionHooked) { + process.__capUnhandledRejectionHooked = true; + process.on("unhandledRejection", (reason) => { + if (!_DEBUG) return; + try { + const r = reason && reason.stack ? reason.stack : String(reason); + process.stderr.write(`[host-unhandledRejection] ${typeof reason} ${JSON.stringify(reason).slice(0, 200)} ${r}\n`); + } catch (_) {} + }); + // Guest scripts (rotated pe/FeiLin bundles) can throw synchronous errors + // that surface as uncaughtExceptions. Without a handler, happy-dom's + // exception observer (or Bun's default) terminates the whole proxy — + // a single bad pe version must only fail that one solve, not the server. + process.on("uncaughtException", (err) => { + try { + const msg = err && err.message ? err.message : String(err); + process.stderr.write(`[captcha-guest-uncaught] ${msg}\n`); + } catch (_) {} + }); + } + // Guest console is silent unless CAPTCHA_DEBUG — piping every SDK log to + // stderr spams journald and slows mints under systemd. + const noop = () => {}; + const guestConsole = _DEBUG + ? { + log: (...a) => process.stderr.write(`[guest-log] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + warn: (...a) => process.stderr.write(`[guest-warn] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + error: (...a) => process.stderr.write(`[guest-err] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + info: (...a) => process.stderr.write(`[guest-info] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + debug: (...a) => process.stderr.write(`[guest-debug] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + trace: (...a) => process.stderr.write(`[guest-trace] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + } + : { log: noop, warn: noop, error: noop, info: noop, debug: noop, trace: noop }; + const w = new Window({ + url: "https://zcode.z.ai/", + console: guestConsole, + settings: { + enableJavaScriptEvaluation: true, + enableImageFileLoading: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + navigator: { userAgent: fp.userAgent }, + viewport: { width: fp.screen.w, height: fp.screen.h, devicePixelRatio: 1 }, + fetch: { + disableSameOriginPolicy: true, + interceptor, + }, + }, + }); + + // Reach into the frame for cookie container + frame ref (host side helpers). + // WindowBrowserContext imported at module scope + const browserFrame = new WindowBrowserContext(w).getBrowserFrame(); + global.__browserFrame = browserFrame; + global.__cookieContainer = browserFrame.page.context.cookieContainer; + + // Cookie priming + for (const raw of cookies) { + try { + const u = new URL("https://zcode.z.ai/"); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie = { + name: pair[0].trim(), + value: pair.slice(1).join("=").trim(), + url: u.origin, + domain: u.hostname, + path: "/", + }; + for (const p of parts.slice(1)) { + const kv = p.trim().split(/=(.*)/s); + const k = (kv[0] || "").toLowerCase(); + if (k === "domain" && kv[1]) cookie.domain = kv[1]; + if (k === "path" && kv[1]) cookie.path = kv[1]; + if (k === "expires") cookie.expires = new Date(kv[1]).getTime(); + if (k === "max-age") cookie.maxAge = parseInt(kv[1], 10); + if (k === "httponly") cookie.httpOnly = true; + if (k === "secure") cookie.secure = true; + if (k === "samesite") cookie.sameSite = kv[1]; + } + browserFrame.page.context.cookieContainer.addCookies([cookie]); + } catch (_) {} + } + + const visitorId = crypto.randomUUID(); + const deviceMid = crypto.randomUUID(); + const pre = [ + { name: "zcode_visitor_id", value: visitorId, domain: "zcode.z.ai" }, + { name: "zcode_device_mid", value: deviceMid, domain: "zcode.z.ai" }, + { name: "visitor_id", value: visitorId, domain: "zcode.z.ai", httpOnly: true }, + ]; + for (const c of pre) { + try { + browserFrame.page.context.cookieContainer.addCookies([{ ...c, url: "https://zcode.z.ai", path: "/" }]); + } catch (_) {} + } + + // Apply polyfills + masking BEFORE the SDK script runs. + // Bun compatibility: happy-dom's VM realm isolation doesn't apply under + // Bun — script tags execute against the host globalThis, where bare + // `window`/`document`/`location` identifiers don't exist. Node needs none + // of this (its VM context resolves them natively). We alias the current + // solve's window on globalThis and remove the aliases when the window is + // destroyed, so concurrent solves with window reuse stay consistent. + applyPolyfills(w); + installNativeToString(w); + // Guest timer scope must exist BEFORE the eval hook wraps any script. + installGuestScope(w); + installEvalInstrumentation(w); + // Bun alias pass runs AFTER polyfills so polyfilled props (Option, Video, + // alert, ...) are visible to guest scripts via globalThis too. + const needsGlobalAlias = typeof Bun !== "undefined"; + if (needsGlobalAlias) { + const g = globalThis; + installGlobalWindowAlias(g, w); + } + if (w.Error) { + w.Error.prepareStackTrace = Error.prepareStackTrace; + } + w.eval(GUEST_EVAL_PATCH); + + // Write the page HTML (loads the SDK script) + w.document.write(HTML); + + w.AliyunCaptchaConfig = { region, prefix }; + + return { window: w, browserFrame }; +} + +// Bun-only: alias the active window on globalThis (script tags run in the +// host realm under Bun). Every own enumerable window property is exposed as a +// getter so guest scripts resolving bare identifiers (window, document, +// XMLHttpRequest, Range, HTMLElement, ...) find them, exactly as Node's VM +// realm would. Removed again in destroyDom. +// Names that must NOT be shadowed on globalThis — Bun/Node host internals the +// window happens to expose but the host runtime depends on. +const HOST_CRITICAL_GLOBALS = new Set([ + "process", "Bun", "console", "performance", "crypto", "fetch", + "queueMicrotask", "structuredClone", "TextEncoder", "TextDecoder", + // Timers stay pristine on globalThis so Bun internals keep real Node timer + // objects (`.unref()`); guest code gets the window's registry lexically. + "setTimeout", "setInterval", "clearTimeout", "clearInterval", + // NOTE: requestAnimationFrame/cancelAnimationFrame were removed from this + // list (2026-09-06). Bun has no native rAF, so skipping the alias left a + // bare `requestAnimationFrame` in the FeiLin bundle unresolvable (9 call + // sites, only one `typeof`-guarded) — the same silent fingerprint + // degradation that `print` caused. Aliasing the window's implementation + // shadows nothing on the host. + // NOTE: `print` was removed from this list (2026-08-29). The polyfill + // defines a harmless no-op on the window, but the alias pass skipped it, + // so under Bun (guest scripts run in the HOST realm) the Aliyun pe risk + // engine hit a bare `print` reference → ReferenceError → broken + // fingerprint chain → degraded solve success rate (711 WINDOW-ERRORs in + // one day). Bun's host global has no native `print`, so aliasing the + // stub shadows nothing critical. + "URL", "URLSearchParams", "AbortController", "AbortSignal", + "ReadableStream", "WritableStream", "TransformStream", "Blob", "File", + "FormData", "Headers", "Request", "Response", "Event", "EventTarget", + "MessageChannel", "MessagePort", "Buffer", "global", "globalThis", + // JS intrinsics — GlobalWindow re-exposes them as class fields; the host + // versions are fine, so never shadow them. + "Array", "ArrayBuffer", "Boolean", "DataView", "Date", "Error", + "EvalError", "Float32Array", "Float64Array", "Function", "Infinity", + "Int8Array", "Int16Array", "Int32Array", "Intl", "JSON", "Map", "Math", + "NaN", "Number", "Object", "Promise", "RangeError", "ReferenceError", + "RegExp", "Reflect", "Set", "String", "Symbol", "SyntaxError", "TypeError", + "URIError", "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", + "WeakMap", "WeakSet", "decodeURI", "decodeURIComponent", "encodeURI", + "encodeURIComponent", "escape", "isFinite", "isNaN", "parseFloat", + "parseInt", "unescape", "eval", +]); +// Window methods that exist as prototype members, not own props — the alias +// pass must include them so guest bare-name references resolve (moveBy, +// scrollTo, ... are referenced by the FeiLin fingerprint SDK). +const EXTRA_WINDOW_PROPS = [ + "moveBy", "moveTo", "resizeBy", "resizeTo", "scrollTo", "scrollBy", "scroll", + "open", "close", "stop", "focus", "blur", "print", "alert", "confirm", + "prompt", "getSelection", "find", +]; +// Subset of the above that a real browser implements as no-op-ish window +// methods. When the tombstone expires these become harmless stubs instead of +// being deleted, so a straggling guest callback that still calls `moveBy()` +// completes silently rather than raising a fatal ReferenceError. +const INERT_WINDOW_METHODS = new Set(EXTRA_WINDOW_PROPS); + +// Ref-count: the pool solves in parallel waves; each window must keep the +// aliases alive until the LAST concurrent window is destroyed, otherwise one +// destroyDom() pulls `window` out from under a sibling mid-solve. +let _aliasRefCount = 0; + +// Post-teardown tombstone (see removeGlobalWindowAlias): how long window- +// sourced alias getters keep resolving — to the CLOSED window — after the +// last destroyDom. Guest (FeiLin) async fingerprint chains ride host +// machinery (fetch/promise continuations) and can outlive the window; a +// hard delete turns their next bare `Text`/`document` reference into an +// uncaught ReferenceError (v4.5.2 field report: "Text is not defined" from +// feilin005.js). The closed window's objects stay readable, so stragglers +// run harmlessly to completion; a new solve wave (generation bump) cancels +// the pending deletion entirely. The pristine host setTimeout captured at +// module load schedules it — never the aliased one. +const ALIAS_TOMBSTONE_MS = 30_000; +let _aliasGeneration = 0; +let _tombstoneMs = ALIAS_TOMBSTONE_MS; +const _hostSetTimeout = globalThis.setTimeout; +// Every getter this module installs on the alias target (generic window +// forwarders, window/self/top/parent, dual timers, __capWindowFor). The +// host-global snapshot skips descriptors whose getter is in here: a wave +// that starts inside a previous wave's grace period finds OUR OWN stale +// accessors still on `g`, and saving them would "restore" window accessors +// at removal — permanently pinning the first closed window (review-caught +// 2026-08-31). Host getters (Bun's navigator/self accessors) are never in +// this set and always flow to the restore path. +const _aliasGetters = new WeakSet(); + +// Same save/restore contract for every HOST-EXISTING global the alias pass +// overwrites (see installGlobalWindowAlias for the rationale). Captured on the +// FIRST install of a wave (globals are pristine then), restored on the last +// remove. +let _savedHostGlobalDescriptors: Record | undefined; + +// Guest timer/console routing is LEXICAL (see the guest scope section above): +// bare `setTimeout`/`console` inside guest source resolve through the `with` +// scope to this window's own objects. No stack sniffing — the previous +// `/alicdn/.test(new Error().stack)` predicate answered "who is calling?" when +// the question is "who owns this?", and got both directions wrong (immortal +// guest heartbeats; host timers stripped of `.unref`). +// +// So globalThis keeps the PRISTINE host timers at all times, and Bun internals +// (node:_http_server keep-alive, undici, AbortSignal.timeout) always get real +// Node timer objects with an intact ref/unref contract. + +// Both take (g, w) explicitly so tests can drive the lifecycle against a +// sandbox global. `tombstoneMs` (tests only) shortens the grace period. +function installGlobalWindowAlias(g, w, tombstoneMs?) { + // Clamp a negative refcount: an unbalanced remove would otherwise land the + // NEXT install at 0 instead of 1 and skip the host-global snapshot, so + // teardown could never restore what it overwrote. + if (_aliasRefCount < 0) _aliasRefCount = 0; + _aliasRefCount += 1; + _aliasGeneration += 1; // cancels any pending tombstone from a prior wave + _tombstoneMs = typeof tombstoneMs === "number" ? tombstoneMs : ALIAS_TOMBSTONE_MS; + // Build the alias name set FIRST, then snapshot every HOST-EXISTING global + // in it BEFORE anything is aliased. The generic props loop below overwrites + // them with window-forwarding accessors (GlobalWindow own props outside + // HOST_CRITICAL_GLOBALS); a capture taken after it would save those + // accessors and the post-remove restore would reinstate accessors onto a + // closed window. The snapshot covers atob/btoa (client-signing's base64 — + // field-reported ReferenceError), WebSocket, MessageEvent, CustomEvent, + // navigator, self, ... — every host global the window happens to expose. + const props = new Set(Object.getOwnPropertyNames(w)); + for (const name of EXTRA_WINDOW_PROPS) props.add(name); + // also walk the prototype chain one level (BrowserWindow getters like + // navigator/location live there in some versions) + for (const proto = Object.getPrototypeOf(w); proto && proto !== Object.prototype;) { + for (const name of Object.getOwnPropertyNames(proto)) props.add(name); + break; + } + if (_aliasRefCount === 1 && !_savedHostGlobalDescriptors) { + const saved: Record = {}; + for (const prop of props) { + if (prop === "constructor" || HOST_CRITICAL_GLOBALS.has(prop)) continue; + try { + const d = Object.getOwnPropertyDescriptor(g, prop); + // Skip OUR OWN stale accessors from a previous wave that was + // cancelled mid-grace (retry ladder / pool bursts start the next + // wave within the 30s tombstone): saving them would "restore" + // window-forwarding accessors at removal and permanently pin the + // first closed window on globalThis. They are window-sourced — the + // new wave's tombstone owns their cleanup. Host getters (Bun's + // navigator/self accessors) are never in the WeakSet and keep + // flowing to the restore path. + if (d && d.get && _aliasGetters.has(d.get)) continue; + if (d) saved[prop] = d; + } catch (_) {} + } + for (const prop of ["window", "self", "top", "parent", "__capWindowFor"]) { + try { + const d = Object.getOwnPropertyDescriptor(g, prop); + if (d && d.get && _aliasGetters.has(d.get)) continue; + if (d && !saved[prop]) saved[prop] = d; + } catch (_) {} + } + _savedHostGlobalDescriptors = saved; + } + for (const prop of props) { + if (HOST_CRITICAL_GLOBALS.has(prop)) continue; + try { + const getter = function () { return w[prop]; }; + _aliasGetters.add(getter); + Object.defineProperty(g, prop, { + get: getter, + set(v) { + try { w[prop] = v; } catch (_) {} + }, + configurable: true, + }); + } catch (_) {} + } + // w.window/self may not exist as own props on this happy-dom build + for (const prop of ["window", "self", "top", "parent"]) { + try { + const getter = function () { return w; }; + _aliasGetters.add(getter); + Object.defineProperty(g, prop, { get: getter, configurable: true }); + } catch (_) {} + } + // Timers are deliberately NOT aliased: globalThis keeps Bun's pristine + // functions so node:_http_server keep-alive, undici and AbortSignal.timeout + // always receive real Node timer objects with an intact `.unref()`. Guest + // code reaches the window's registry through the lexical `with` scope + // instead, so its callbacks still die in happyDOM.close(). + // Dynamic catch-all: guest code occasionally references window methods that + // only exist on the prototype (moveBy, scrollTo, ...) or lands mid-solve on + // new props. Proxy fallback for any still-missing global property. + try { + const capGetter = function () { return w; }; + _aliasGetters.add(capGetter); + Object.defineProperty(g, "__capWindowFor", { + get: capGetter, + configurable: true, + }); + } catch (_) {} +} +function removeGlobalWindowAlias(g, w) { + _aliasRefCount -= 1; + if (_aliasRefCount > 0) return; + // Host-contract globals come back IMMEDIATELY: every HOST-EXISTING global + // the wave overwrote — atob/btoa (client-signing's JWT base64 — the + // field-reported ReferenceError), WebSocket/MessageEvent/navigator/self/... + // The timers and console were never aliased, so nothing to restore there. + const restored = new Set(); + if (_savedHostGlobalDescriptors) { + for (const [name, desc] of Object.entries(_savedHostGlobalDescriptors)) { + try { Object.defineProperty(g, name, desc); } catch (_) {} + restored.add(name); + } + _savedHostGlobalDescriptors = undefined; + } + // Window-sourced globals get a TOMBSTONE grace period instead of an + // immediate delete (see the ALIAS_TOMBSTONE_MS comment). Collect what is + // still accessor-aliased, EXCLUDING the restored set — a restored host + // descriptor may itself be a getter (Bun's navigator/self are accessors) + // and must never be tombstone-deleted. + const generation = _aliasGeneration; + const names: string[] = []; + try { + for (const name of Object.getOwnPropertyNames(w)) { + if (restored.has(name)) continue; + try { + if (Object.getOwnPropertyDescriptor(g, name)?.get) names.push(name); + } catch (_) {} + } + for (const prop of ["window", "self", "top", "parent", "__capWindowFor"]) { + if (restored.has(prop)) continue; + try { + if (Object.getOwnPropertyDescriptor(g, prop)?.get) names.push(prop); + } catch (_) {} + } + } catch (_) {} + try { + const t = _hostSetTimeout(() => { + if (generation !== _aliasGeneration || _aliasRefCount > 0) return; + for (const name of names) { + try { + if (!Object.getOwnPropertyDescriptor(g, name)?.get) continue; + // Do NOT `delete`: a straggler still reading the name would get a + // ReferenceError, which is fatal in the host realm. Leave an inert + // value instead — the reference resolves, the call is a no-op, and + // nothing keeps the closed window alive. The window methods guest + // fingerprint code probes (moveBy/scrollTo/...) are no-ops in a real + // browser anyway, so `undefined` is a faithful stand-in for the rest. + const stub = INERT_WINDOW_METHODS.has(name) ? () => {} : undefined; + Object.defineProperty(g, name, { + value: stub, + configurable: true, + writable: true, + }); + } catch (_) {} + } + }, _tombstoneMs); + try { + if (t && typeof t.unref === "function") t.unref(); + } catch (_) {} + } catch (_) {} +} + +function destroyDom(win) { + try { + const cap = win.document.getElementById("cap"); + if (cap) cap.replaceChildren(); + win.happyDOM.close(); + } catch (_) {} + try { + global.__cookieContainer = null; + global.__browserFrame = null; + } catch (_) {} + try { + if (typeof Bun !== "undefined") removeGlobalWindowAlias(globalThis, win); + } catch (_) {} + // The scope holds window-bound timer functions; dropping it releases the + // closed window and makes any straggler's `new Function` fall back to the + // host constructor (harmless: the window registry is already cleared). + removeGuestScope(win); + try { shutdownSyncFetchWorker(); } catch (_) {} +} + +function extractVerifyParam(param) { + let verifyParam = param; + if (param && typeof param === "object") { + verifyParam = param.verifyParam || param.data || param.param; + } + if (!verifyParam || String(verifyParam).length < 20) { + throw new Error("solver returned empty param: " + JSON.stringify(param)); + } + const str = String(verifyParam); + // Strict validation: a REAL Aliyun verify param is ~280 chars of base64 + // JSON containing certifyId + sceneId + isSign + a long securityToken. + // Len-76 junk like {"certifyId":"70bdb",...,"isSign":true} (no securityToken) + // comes from a degraded SDK result path and WILL 3007 upstream — never let + // it out of the solver. + if (str.length < 200) { + throw new Error( + "verify param too short (" + str.length + " chars) — degraded result, refusing: " + str.slice(0, 80), + ); + } + try { + const decoded = JSON.parse(Buffer.from(str, "base64").toString("utf8")); + const secTok = decoded && (decoded.securityToken || decoded.SecurityToken); + if (!secTok || String(secTok).length < 50) { + throw new Error( + "verify param missing securityToken — refusing degraded result: " + str.slice(0, 80), + ); + } + } catch (err) { + if (err instanceof SyntaxError || /securityToken/.test(String(err.message))) { + throw err instanceof SyntaxError + ? new Error("verify param not base64-JSON: " + str.slice(0, 80)) + : err; + } + throw err; + } + return str; +} + +function handleCaptchaResult(result) { + if (result && typeof result === "object" && result.verifyResult === false) { + throw new Error( + "verify rejected: " + + JSON.stringify({ verifyCode: result.verifyCode, certifyId: result.certifyId }), + ); + } + return result; +} + +// ── Window reuse pool ────────────────────────────────────────────────────── +// Reusing one happy-dom window across solves cuts CPU ~48% (measured: 426ms vs +// 815ms per solve) by amortizing the DOM boot + SDK script load. Enabled via +// CAPTCHA_WINDOW_REUSE=1 (or solveTraceless({reuseWindow:true})). The window +// is discarded after `maxSolves` (memory growth), after any stall/failure +// (fresh InitCaptchaV3 rolls a new pe version), or after `maxIdleMs` idle. +const _reusePool = { window: null, browserFrame: null, solves: 0, lastUsedAt: 0 }; +const REUSE_MAX_SOLVES = Number(process.env.CAPTCHA_REUSE_MAX_SOLVES || 25); +const REUSE_MAX_IDLE_MS = Number(process.env.CAPTCHA_REUSE_MAX_IDLE_MS || 120_000); + +function takeReusableWindow() { + const p = _reusePool; + if (!p.window) return null; + if (p.solves >= REUSE_MAX_SOLVES) { discardReusableWindow(); return null; } + if (Date.now() - p.lastUsedAt > REUSE_MAX_IDLE_MS) { discardReusableWindow(); return null; } + return { window: p.window, browserFrame: p.browserFrame, reused: true }; +} +function stageReusableWindow(window, browserFrame) { + _reusePool.window = window; + _reusePool.browserFrame = browserFrame; + _reusePool.solves = 0; + _reusePool.lastUsedAt = Date.now(); +} +function discardReusableWindow() { + const p = _reusePool; + if (p.window) { + try { destroyDom(p.window); } catch (_) {} + } + p.window = null; + p.browserFrame = null; + p.solves = 0; +} +function noteWindowSolved() { + _reusePool.solves += 1; + _reusePool.lastUsedAt = Date.now(); +} + +// ── Guest error capture (read side) ──────────────────────────────────────── +// GUEST_EVAL_PATCH records every guest window error into window.__capErrs +// (capped, deduped) instead of console-printing them: the Aliyun/FeiLin SDKs +// throw benign uncaught TypeErrors inside happy-dom on every solve and the +// solve still succeeds, so printing them is pure console spam. The buffer is +// surfaced only when a solve FAILS — that's when guest errors are +// actionable. CAPTCHA_DEBUG=1 streams them live again as +// [WINDOW-ERROR]/[UH-REASON]. +function guestErrorSummary(w, max = 4) { + try { + const errs = w && w.__capErrs; + if (!errs || !errs.length) return ""; + const total = errs.reduce((a, e) => a + ((e && e.n) || 1), 0); + const parts = errs.slice(0, max).map((e) => { + const n = e && e.n && e.n > 1 ? `x${e.n}` : ""; + return `${(e && e.k) || "?"}${n}: ${String((e && e.m) || "?").slice(0, 120)}`; + }); + return ` guestErrors(${total}): ${parts.join(" || ")}`; + } catch (_) { + return ""; + } +} + +async function solveTraceless(opts) { + const scene = opts.scene || "11xygtvd"; + const region = opts.region || "sgp"; + const prefix = opts.prefix || "no8xfe"; + const timeoutMs = opts.timeoutMs ?? 30_000; + + const wantReuse = opts.reuseWindow ?? process.env.CAPTCHA_WINDOW_REUSE === "1"; + let dom; + let reused = false; + if (wantReuse) { + dom = takeReusableWindow(); + if (dom) reused = true; + } + if (!dom) { + dom = await createDom(region, prefix); + } + const { window: w, browserFrame } = dom; + const solveStart = Date.now(); + let solveSucceeded = false; + let keepWindow = false; + try { + await waitFor(() => typeof w.initAliyunCaptcha === "function", timeoutMs, 50); + + simulateBehavior(w, 600); + + const param = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("captcha solve timeout")); + }, timeoutMs); // Fail-fast stall detector: healthy solves keep firing XHRs until verify + // (~3s). If no XHR for stallMs and none pending, this pe-VM variant + // stalled (seen across rotated pe.0xx versions) — abort early so the + // caller can retry with a fresh InitCaptchaV3 (new pe version). + // Fail-fast stall detector: healthy solves keep firing XHRs until + // verify (~3s, gaps <2s). If no XHR for 6s, this pe-VM variant stalled + // (seen across rotated pe.0xx versions) — abort early so the caller + // can retry with a fresh InitCaptchaV3 (new pe version). + const stallMs = opts.stallMs ?? Number(process.env.CAPTCHA_STALL_MS || 6_000); + const stallTimer = setInterval(() => { + const last = _requestLog[_requestLog.length - 1]; + if (last && Date.now() - last.at > stallMs) { + const peUrl = (() => { try { return w.__lastPeUrl || "?"; } catch (_) { return "?"; } })(); + noteStallAndMaybeEvict(peUrl); + clearTimeout(timer); + clearInterval(stallTimer); + reject(new Error("captcha solve stall")); + } + }, 500); + const finish = (fn) => (value) => { + clearTimeout(timer); + clearInterval(stallTimer); + fn(value); + }; + try { + w.initAliyunCaptcha({ + SceneId: scene, + mode: "popup", + region, + prefix, + language: "en", + element: "#cap", + button: "#btn", + captchaLogoImg: "", + showErrorTip: false, + getInstance: (inst) => { + try { + (inst.startTracelessVerification || inst.show).call(inst); + } catch (e) { + finish(reject)(new Error(`start: ${e.message}`)); + } + }, + success: (result) => { + try { + finish(resolve)(handleCaptchaResult(result)); + } catch (err) { + finish(reject)(err); + } + }, + fail: (err) => finish(reject)(new Error(`fail: ${JSON.stringify(err)}`)), + onError: (err) => finish(reject)(new Error(`onError: ${JSON.stringify(err)}`)), + }); + } catch (err) { + clearTimeout(timer); + reject(err); + } + }); + + // Success clears this pe's stall history so future isolated stalls can + // still trigger eviction after two genuine consecutive failures. Also + // clears the guest error buffer: a pooled window's next failure must + // only report errors from solves after this success. + try { + const okPe = w.__lastPeUrl; + if (okPe) _stallCounts.delete(okPe); + w.__capErrs = []; + } catch (_) {} + + solveSucceeded = true; + const out = extractVerifyParam(param); + if (wantReuse) { + if (reused) noteWindowSolved(); + else stageReusableWindow(w, browserFrame); + keepWindow = true; + } + return out; + } catch (err) { + // Attach captured guest window errors to the failure — the only situation + // where they are actionable (a successful solve makes them irrelevant). + const summary = guestErrorSummary(w); + if (summary) { + try { + err.message = `${err && err.message ? err.message : String(err)} |${summary}`; + } catch (_) {} + } + throw err; + } finally { + // Reuse mode: on success the window stays pooled (keepWindow) for the next + // solve — a ~48% CPU cut. On failure it is destroyed: a stalled window must + // not poison later solves, and the retry rolls a fresh pe anyway. + if (!keepWindow) { + if (_reusePool.window === w) _reusePool.window = null; + destroyDom(w); + } + } +} + +export { solveTraceless }; diff --git a/src/adapters/zcode-start-plan/system-blocks.json b/src/adapters/zcode-start-plan/system-blocks.json new file mode 100644 index 0000000000..987587dd32 --- /dev/null +++ b/src/adapters/zcode-start-plan/system-blocks.json @@ -0,0 +1,23 @@ +[ + { + "type": "text", + "text": "You are ZCode, an interactive coding agent", + "cache_control": { + "type": "ephemeral" + } + }, + { + "type": "text", + "text": "\nYou are an interactive ZCode agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n- Reference code as `file_path:line_number` — it's clickable.", + "cache_control": { + "type": "ephemeral" + } + }, + { + "type": "text", + "text": "# Environment\nYou have been invoked in the following environment:\n- Primary working directory: unknown\n- Is a git repository: no\n- Platform: unknown\n- Shell: unknown\n- OS Version: unknown", + "cache_control": { + "type": "ephemeral" + } + } +] diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ed8af01af9..e6099864c8 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -34,6 +34,7 @@ import { import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; +import { loginZcodeStartPlan, refreshZcodeStartPlanToken } from "./zcode-start-plan"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; @@ -264,6 +265,15 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("kimi"), defaultModel: oauthDefaultModel("kimi"), }, + "zcode-start-plan": { + login: (ctrl) => loginZcodeStartPlan(ctrl), + refresh: refreshZcodeStartPlanToken, + providerConfig: oauthConfig("zcode-start-plan"), + defaultModel: oauthDefaultModel("zcode-start-plan"), + // The plan JWT has no exp claim and no silent refresh: a rejected token is terminal and + // needs a fresh browser login. Never generate background refresh traffic for it. + defaultRefreshPolicy: "disabled", + }, "meta-muse": { login: ctrl => loginMetaMuse(ctrl), refresh: refreshMetaMuseToken, diff --git a/src/oauth/zcode-start-plan.ts b/src/oauth/zcode-start-plan.ts new file mode 100644 index 0000000000..3a14cdfc74 --- /dev/null +++ b/src/oauth/zcode-start-plan.ts @@ -0,0 +1,117 @@ +/** + * ZCode plan OAuth login (CLI flow). + * + * Mirrors the ZCode desktop client's `oauth/cli` device-style flow: + * + * 1. POST /api/v1/oauth/cli/init -> { flow_id, authorize_url, poll_interval_sec } + * 2. the user authorizes `authorize_url` in a browser + * 3. GET /api/v1/oauth/cli/poll/{flow_id} -> pending | ready { token, user, zai } + * + * `token` is the zcode-plan JWT (no `exp` claim — the gateway does not reject by age), and + * `zai.access_token` is stored as the refresh slot for diagnosability. Neither is refreshable + * without a browser round-trip, so a gateway rejection surfaces as re-login. + */ +import { randomBytes } from "node:crypto"; +import type { OAuthController, OAuthCredentials } from "./types"; + +const ZCODE_ORIGIN = "https://zcode.z.ai"; +const SDK_UA = "ZCode/3.11.2"; +const DEFAULT_POLL_INTERVAL_MS = 3000; +const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; + +interface CliInitResponse { + code?: number; + msg?: string; + data?: { + flow_id?: string; + authorize_url?: string; + expires_at?: number; + poll_interval_sec?: number; + }; +} + +interface CliPollResponse { + code?: number; + msg?: string; + data?: { + status?: string; + token?: string; + user?: { user_id?: string; email?: string }; + zai?: { access_token?: string }; + }; +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +const sleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException("aborted", "AbortError")); + }, { once: true }); + }); + +/** Run one full browser login and return the stored credential. */ +export async function loginZcodeStartPlan(ctrl: OAuthController): Promise { + const pollToken = randomBytes(32).toString("hex"); + const initRes = await fetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/init`, { + method: "POST", + headers: { authorization: `Bearer ${pollToken}`, "content-type": "application/json", "user-agent": SDK_UA }, + body: JSON.stringify({ provider: "zai" }), + }); + const init = (await initRes.json().catch(() => undefined)) as CliInitResponse | undefined; + const flowId = nonEmpty(init?.data?.flow_id); + const authorizeUrl = nonEmpty(init?.data?.authorize_url); + if (!initRes.ok || !flowId || !authorizeUrl) { + throw new Error(`ZCode plan login init failed: ${init?.msg ?? `status ${initRes.status}`}`); + } + + ctrl.onAuth?.({ + url: authorizeUrl, + instructions: "Approve the Z.ai authorization in your browser to connect the ZCode plan.", + }); + + const intervalMs = Math.max(1000, (init?.data?.poll_interval_sec ?? 0) * 1000) || DEFAULT_POLL_INTERVAL_MS; + const deadline = Date.now() + LOGIN_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(intervalMs, ctrl.signal); + let poll: CliPollResponse | undefined; + try { + const res = await fetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/poll/${encodeURIComponent(flowId)}`, { + headers: { authorization: `Bearer ${pollToken}`, "user-agent": SDK_UA }, + }); + poll = (await res.json().catch(() => undefined)) as CliPollResponse | undefined; + } catch { + continue; // transient poll errors retry until the flow deadline + } + const data = poll?.data; + if (data?.status === "ready") { + const jwt = nonEmpty(data.token); + if (!jwt) throw new Error("ZCode plan login completed without a JWT"); + return { + refresh: nonEmpty(data.zai?.access_token) ?? "", + access: jwt, + // The plan JWT carries no `exp`; the gateway rejects stale tokens with 401/3012 and the + // adapter surfaces re-login from there. Max expires keeps the shared refresh gate idle. + expires: Number.MAX_SAFE_INTEGER, + email: nonEmpty(data.user?.email), + accountId: nonEmpty(data.user?.user_id), + source: "oauth", + }; + } + if (data?.status === "failed") throw new Error(`ZCode plan login failed: ${poll?.msg ?? "authorization denied"}`); + } + throw new Error("ZCode plan login timed out"); +} + +/** + * No silent refresh exists: the JWT is long-lived but unrefreshable without a browser round. + * Terminal by contract — the shared path marks the account needsReauth and the user re-runs + * `ocx login zcode-start-plan`. + */ +export async function refreshZcodeStartPlanToken(): Promise { + throw new Error("invalid_grant: the ZCode plan JWT cannot be refreshed; reconnect with ocx login zcode-start-plan"); +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 79e6a6bf91..07d34aaf00 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,4 +1,7 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { arch, homedir } from "node:os"; +import { join } from "node:path"; import { effectiveCodexAuthAccountId, fetchMainAccountInfoSnapshot, @@ -91,6 +94,8 @@ const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; const ZAI_BASE_URL = "https://api.z.ai"; const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; +const ZCODE_PLAN_ORIGIN = "https://zcode.z.ai"; +const ZCODE_PLAN_APP_VERSION = process.env.ZCODE_PLAN_APP_VERSION?.trim() || "3.11.2"; const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; @@ -355,6 +360,35 @@ function isCanonicalZaiBaseUrl(baseUrl: string): boolean { || normalized === `${ZAI_CN_BASE_URL}/api/v1`; } +function isCanonicalZcodePlanBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === `${ZCODE_PLAN_ORIGIN}/api/v1/zcode-plan/anthropic` + || normalized === `${ZCODE_PLAN_ORIGIN}/api/v1/zcode-plan`; +} + +/** + * Stable per-install device id the plan gateway's control plane expects on billing calls + * (`X-Device-Mid`; its absence is answered with biz code 3001). Generated once and stored + * under the OpenCodex config dir; `ZCODE_DEVICE_MID` overrides (e.g. to reuse the desktop + * client's id so the gateway sees one continuous device). + */ +function zcodePlanDeviceMid(): string { + const fromEnv = process.env.ZCODE_DEVICE_MID?.trim(); + if (fromEnv) return fromEnv; + const dir = join(homedir(), ".config", "opencodex"); + const file = join(dir, "zcode-plan-device-mid"); + try { + const stored = readFileSync(file, "utf8").trim(); + if (stored) return stored; + } catch {} + const mid = randomUUID(); + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(file, mid, { mode: 0o600 }); + } catch {} + return mid; +} + function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1"; @@ -1684,7 +1718,8 @@ export function supportsPerAccountQuota(provider: string): boolean { } function explicitAccountReader(provider: string): boolean { - return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"; + return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code" + || provider === "zcode-start-plan"; } export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode { @@ -1962,6 +1997,7 @@ function explicitQuotaDestination(provider: string, config: OcxProviderConfig): if (config.disabled === true || config.authMode !== "oauth") return false; if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl); if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl); + if (provider === "zcode-start-plan") return isCanonicalZcodePlanBaseUrl(config.baseUrl); // These readers use fixed canonical billing origins, never config.baseUrl. return provider === "xai" || provider === "cursor"; } @@ -1989,6 +2025,7 @@ async function readExplicitAccountQuota(provider: string, accountId: string, con case "cursor": result = await fetchCursorQuota(provider, accessToken); break; case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break; case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break; + case "zcode-start-plan": result = await fetchZcodeStartPlanQuota(provider, accessToken); break; default: return null; } return { result, identity, isCurrent }; @@ -2311,6 +2348,81 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig, acces return quota ? report(provider, "kimi:usages", quota) : null; } +/** + * ZCode plan gateway billing balance (`/api/v1/zcode-plan/billing/balance`) — the same + * control plane the ZCode desktop client reads. Auth is the plan JWT; the gateway + * fingerprints control-plane calls, so the identity header set mirrors the client minus + * `X-ZCode-Agent` (which the client itself omits on control-plane fetches). Balance rows + * are plan-specific pools (`show_name`, used/total units), surfaced as custom windows. + */ +async function fetchZcodeStartPlanQuota(provider: string, accessToken: string): Promise { + if (!accessToken) return null; + const platform = `${process.platform}-${arch()}`; + const language = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().locale || "unknown"; + } catch { + return "unknown"; + } + })(); + const timezone = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown"; + } catch { + return "unknown"; + } + })(); + const response = await fetch( + `https://zcode.z.ai/api/v1/zcode-plan/billing/balance?app_version=${encodeURIComponent(ZCODE_PLAN_APP_VERSION)}&platform=${encodeURIComponent(platform)}`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "HTTP-Referer": "https://zcode.z.ai", + "User-Agent": `ZCode/${ZCODE_PLAN_APP_VERSION}`, + "X-ZCode-App-Version": ZCODE_PLAN_APP_VERSION, + "X-Title": "Z Code@cli", + "X-Release-Channel": process.env.ZCODE_ENV?.trim().toLowerCase() === "test" ? "test" : "production", + "X-Client-Language": language, + "X-Client-Timezone": timezone, + "X-Platform": platform, + "X-Os-Category": process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux", + "X-Device-Mid": zcodePlanDeviceMid(), + }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + if (!body || (typeof body.code === "number" && body.code !== 0)) return null; + const data = asRecord(body.data) ?? {}; + const balances = Array.isArray(data.balances) ? data.balances : []; + const windows: ProviderQuotaWindow[] = []; + for (const row of balances) { + const entry = asRecord(row); + if (!entry) continue; + const label = typeof entry.show_name === "string" && entry.show_name.trim() ? entry.show_name.trim() : "balance"; + const total = toFiniteNumber(entry.total_units ?? entry.totalUnits); + const used = toFiniteNumber(entry.used_units ?? entry.usedUnits); + if (total === undefined || total <= 0) continue; + const ratio = used === undefined ? (total - (toFiniteNumber(entry.remaining_units ?? entry.remainingUnits) ?? total)) / total : used / total; + const percent = normalizePercent(ratio * 100); + if (percent === undefined) continue; + const expiresAt = toFiniteNumber(entry.expires_at ?? entry.expiresAt); + windows.push({ label, percent, ...(expiresAt !== undefined ? { resetAt: expiresAt } : {}) }); + } + if (windows.length === 0) return AUTHORITATIVE_EMPTY_QUOTA; + return report(provider, "zcode-start-plan:billing-balance", { + customWindows: windows, + updatedAt: Date.now(), + }); +} + /** * Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits, * normalized to a percent with an optional reset timestamp. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c4ea553d2d..8f0d464526 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2546,6 +2546,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "GLM-5.3 coding subscription", models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, + // glm-5.3-flash is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash): text+image in. + // The 5.x rows minus flash stay in noVisionModels (sidecar-described images). + modelInputModalities: { "glm-5.3-flash": ["text", "image"] }, // Z.AI's OpenAI path returns 400 code 1211 for bracketed model ids. modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, @@ -2555,6 +2558,32 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), preserveReasoningContentModels: ZAI_GLM_5X_MODELS, }, + // ZCode plan gateway (zcode.z.ai): serves the Z.ai Start Plan quota bundled with the + // ZCode desktop client. Login is the gateway's OAuth CLI flow (`ocx login + // zcode-start-plan`); the JWT arrives as apiKey through oauth rotation and is sent as + // `Authorization: Bearer` with client-identical identity headers. The route is exempt + // from V4 client signing; Aliyun WAF captcha challenges are solved in-process by the + // adapter's traceless solver. Model ids follow the gateway's client config. + { + id: "zcode-start-plan", + label: "ZCode — Z.ai Start Plan", + baseUrl: "https://zcode.z.ai/api/v1/zcode-plan/anthropic", + adapter: "zcode-start-plan", + authKind: "oauth", + oauthId: "zcode-start-plan", + featured: true, + dashboardUrl: "https://zcode.z.ai", + defaultModel: "GLM-5.3", + note: "Z.ai Start Plan quota from the ZCode gateway (OAuth login)", + models: ["GLM-5.3", "GLM-5.3-Flash", "GLM-5.2", "GLM-5-Turbo"], + modelContextWindows: { "GLM-5.3": 1_000_000, "GLM-5.3-Flash": 1_000_000, "GLM-5.2": 1_000_000, "GLM-5-Turbo": 200_000 }, + // The gateway's Anthropic route has no /models listing; `models` is the allowlist + // published by its client config. Live discovery would 404 every startup. + liveModels: false, + // GLM-5.3-Flash accepts image input on this gateway (per the client config the + // desktop client loads); GLM-5.3 and GLM-5.2 stay text-only. + modelInputModalities: { "GLM-5.3-Flash": ["text", "image"] }, + }, // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a // different host and billing product from the `zai` coding-plan subscription above. // The id is deliberately NOT `glm` or `glm-cn`: both are already bound in FREE_PROVIDER_DIRECTORY diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index e2ba5a2029..fda8eb7dce 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -25,7 +25,7 @@ import { } from "../../oauth"; import { OAuthMutationBusyError, removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { emailMaskingEnabled } from "../../lib/privacy"; +import { emailMaskingEnabled, projectEmail } from "../../lib/privacy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; @@ -140,6 +140,45 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ providers: listOAuthProviders() }); } + // Human-readable attribution for the request log's opaque account labels. Log rows carry + // `accountLogLabel` (one-way: `o` for oauth accounts, the + // `p` label from the Codex pool config). This maps those labels back to the email + // (masked per the privacy setting) and plan so the dashboard can show who served a turn. + if (url.pathname === "/api/account-labels" && req.method === "GET") { + const mask = emailMaskingEnabled(config); + const { getAccountSet } = await import("../../oauth/store"); + const { oauthAccountLogLabel } = await import("../../codex/account-label"); + const labels: Array<{ label: string; provider: string; email?: string; plan?: string }> = []; + for (const [providerName, provider] of Object.entries(config.providers ?? {})) { + if (provider.authMode !== "oauth" || provider.disabled === true) continue; + let set: ReturnType; + try { + set = getAccountSet(providerName); + } catch { + continue; + } + for (const account of set?.accounts ?? []) { + const email = account.credential.email; + labels.push({ + label: oauthAccountLogLabel(account.id, providerName), + provider: providerName, + ...(email ? { email: projectEmail(email, mask) ?? undefined } : {}), + }); + } + } + for (const entry of config.codexAccounts ?? []) { + if (!entry.logLabel) continue; + const email = entry.email; + labels.push({ + label: entry.logLabel, + provider: "openai", + ...(email ? { email: projectEmail(email, mask) ?? undefined } : {}), + ...(entry.plan ? { plan: entry.plan } : {}), + }); + } + return jsonResponse({ labels }); + } + // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker. if (url.pathname === "/api/key-providers" && req.method === "GET") { return jsonResponse({ providers: listKeyLoginProviders() }); diff --git a/tests/providers/zcode-start-plan.test.ts b/tests/providers/zcode-start-plan.test.ts new file mode 100644 index 0000000000..7c52b3aeb7 --- /dev/null +++ b/tests/providers/zcode-start-plan.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { + buildLlmIdentityHeaders, + buildTraceHeaders, + isCaptchaChallenge, +} from "../../src/adapters/zcode-start-plan"; +import { + buildStartPlanSystem, + transformStartPlanBody, + userIdFromJwt, +} from "../../src/adapters/zcode-start-plan/body-transform"; +import { + buildZcodeIdentityHeaders, + buildZcodeTraceHeaders, + isZcodePlanMeteredEndpoint, +} from "../../src/adapters/zcode-identity"; + +describe("zcode identity attribution eligibility", () => { + test("plan-metered GLM endpoints qualify (coding paths + plan gateway)", () => { + expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/coding/paas/v4")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/coding/paas/v4")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/v1")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://zcode.z.ai/api/v1/zcode-plan/anthropic")).toBe(true); + }); + + test("pay-as-you-go and unrelated endpoints do not qualify", () => { + expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/paas/v4")).toBe(false); + expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/paas/v4")).toBe(false); + expect(isZcodePlanMeteredEndpoint("https://api.openai.com/v1")).toBe(false); + expect(isZcodePlanMeteredEndpoint(undefined)).toBe(false); + }); + + test("coding-plan scope adds the x-query-id/x-session-id pair; start-plan scope omits it", () => { + const coding = buildZcodeTraceHeaders("coding-plan"); + const start = buildZcodeTraceHeaders("start-plan"); + expect(coding["x-query-id"]).toBeDefined(); + expect(coding["x-session-id"]).toBeDefined(); + expect(start["x-query-id"]).toBeUndefined(); + expect(start["x-session-id"]).toBeUndefined(); + for (const t of [coding, start]) { + expect(t["x-zcode-session-type"]).toBe("main"); + expect(t["x-request-id"]).toBeDefined(); + expect(t["x-zcode-trace-id"]).toBeDefined(); + } + }); + + test("identity headers carry the ZCode client attribution", () => { + const h = buildZcodeIdentityHeaders({ userAgentSuffix: "ai-sdk/anthropic/3.0.81" }); + expect(h["User-Agent"]).toMatch(/^ZCode\/3\.11\.2 ai-sdk\/anthropic\/3\.0\.81$/); + expect(h["X-ZCode-Agent"]).toBe("glm"); + expect(h["X-Title"]).toBe("Z Code@cli"); + const plain = buildZcodeIdentityHeaders(); + expect(plain["User-Agent"]).toBe("ZCode/3.11.2"); + }); +}); + +describe("zcode-start-plan gateway body transform", () => { + test("prepends official system blocks and merges the powered-by line into Environment", () => { + const system = buildStartPlanSystem("caller prompt", "GLM-5.3"); + expect(system.length).toBe(4); + expect(system[0].text).toBe("You are ZCode, an interactive coding agent"); + expect(system[2].text).toContain("# Environment"); + expect(system[2].text.endsWith("- You are powered by the model named GLM-5.3.")).toBe(true); + expect(system[3]).toEqual({ type: "text", text: "caller prompt" }); + expect(system[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + test("string and block-array caller system both normalize after the official blocks", () => { + expect(buildStartPlanSystem(undefined)[3]).toBeUndefined(); + const blocks = buildStartPlanSystem([{ type: "text", text: "keep" }, { type: "image" as never }]); + expect(blocks[3]).toEqual({ type: "text", text: "keep" }); + expect(blocks.length).toBe(4); + }); + + test("cache_control: strips stray markers, marks only the last message's last block", () => { + const body = JSON.stringify({ + model: "GLM-5.3", + system: "s", + messages: [ + { role: "user", content: [{ type: "text", text: "a", cache_control: { type: "ephemeral" } }] }, + { role: "assistant", content: [{ type: "text", text: "b", cache_control: { type: "ephemeral" } }] }, + ], + }); + const out = JSON.parse(transformStartPlanBody(body, "GLM-5.3", undefined)); + expect(out.messages[0].content[0].cache_control).toBeUndefined(); + expect(out.messages[1].content[0].cache_control).toEqual({ type: "ephemeral" }); + expect(out.system[0].text).toContain("ZCode"); + }); + + test("metadata.user_id injected from the JWT claim, other metadata preserved", () => { + const body = JSON.stringify({ model: "GLM-5.3", messages: [{ role: "user", content: "hi" }], metadata: { foo: 1 } }); + const out = JSON.parse(transformStartPlanBody(body, "GLM-5.3", "user-123")); + expect(out.metadata).toEqual({ foo: 1, user_id: "user-123" }); + }); + + test("userIdFromJwt decodes the user_id claim", () => { + const payload = Buffer.from(JSON.stringify({ user_id: "abc" })).toString("base64url"); + expect(userIdFromJwt(`x.${payload}.y`)).toBe("abc"); + expect(userIdFromJwt("not-a-jwt")).toBeUndefined(); + }); +}); + +describe("zcode-start-plan identity headers", () => { + test("mirrors the client's LLM companion header shape", () => { + const headers = buildLlmIdentityHeaders(); + expect(headers["HTTP-Referer"]).toBe("https://zcode.z.ai"); + expect(headers["User-Agent"]).toMatch(/^ZCode\/3\.11\.2 ai-sdk\/anthropic\/3\.0\.81$/); + expect(headers["X-ZCode-App-Version"]).toBe("3.11.2"); + expect(headers["X-Title"]).toBe("Z Code@cli"); + expect(headers["X-Release-Channel"]).toBe("production"); + expect(headers["X-Client-Language"].length).toBeGreaterThan(0); + expect(headers["X-Client-Timezone"].length).toBeGreaterThan(0); + expect(headers["X-ZCode-Agent"]).toBe("glm"); + // The LLM path never carries a device id. + expect(headers["X-Device-Mid"]).toBeUndefined(); + }); +}); + +describe("zcode-start-plan trace headers", () => { + test("fresh attribution ids per request", () => { + const first = buildTraceHeaders(); + const second = buildTraceHeaders(); + expect(first["x-zcode-session-type"]).toBe("main"); + expect(first["x-request-id"]).not.toBe(second["x-request-id"]); + expect(first["x-zcode-trace-id"]).not.toBe(second["x-zcode-trace-id"]); + // start-plan requests carry no query/session attribution pair. + expect(first["x-query-id"]).toBeUndefined(); + expect(first["x-session-id"]).toBeUndefined(); + }); +}); + +describe("zcode-start-plan captcha challenge detection", () => { + test("2xx responses are never challenges", () => { + const headers = new Headers({ "x-aliyun-captcha-verify-param": "token" }); + expect(isCaptchaChallenge(200, headers, '{"code":3007}')).toBe(false); + }); + + test("response-header variant", () => { + const headers = new Headers({ "x-aliyun-captcha-verify-param": "token" }); + expect(isCaptchaChallenge(400, headers, undefined)).toBe(true); + const blank = new Headers({ "x-aliyun-captcha-verify-param": " " }); + expect(isCaptchaChallenge(400, blank, undefined)).toBe(false); + }); + + test("in-body 3007 variant, both JSON spacings", () => { + const headers = new Headers(); + expect(isCaptchaChallenge(400, headers, '{"code":3007,"msg":"captcha verify failed"}')).toBe(true); + expect(isCaptchaChallenge(400, headers, '{"code": 3007, "msg": "captcha verify failed"}')).toBe(true); + expect(isCaptchaChallenge(400, headers, '{"code":3001,"msg":"parameter error"}')).toBe(false); + expect(isCaptchaChallenge(400, headers, undefined)).toBe(false); + }); +}); From da73735dc54d8f330654865f6fccdfc8e33cbdb1 Mon Sep 17 00:00:00 2001 From: Eros Date: Sat, 12 Sep 2026 20:46:45 +0100 Subject: [PATCH 3/4] chore: replace empty catch blocks with commented best-effort bodies (PR hygiene) --- ..._866f3f9a-3097-4f86-af44-a594358fdc75.json | 1 + ...83ee9d422c3729e816a64d57f4c246ed617.source | 1233 +++++++++++++++++ ...097-4f86-af44-a594358fdc75-d20eead49b.json | 14 + .../zcode-start-plan/captcha-solver.ts | 172 +-- src/providers/quota.ts | 4 +- 5 files changed, 1336 insertions(+), 88 deletions(-) create mode 100644 gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json create mode 100644 gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source create mode 100644 gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json diff --git a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json new file mode 100644 index 0000000000..ef9a1211e5 --- /dev/null +++ b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json @@ -0,0 +1 @@ +{"touched":["/home/eros/.zcode/workspace/default/opencodex/gui/src/pages/Logs.tsx"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtyq6sgb-258204-db5b687414","createdAt":"2026-09-12T18:35:34.667Z","files":{"src/pages/Logs.tsx":{"existed":true,"snapshot":"9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../src/server/management/oauth-account-routes.ts","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../src/server/management/oauth-account-routes.ts","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-12T18:35:37.507Z"} \ No newline at end of file diff --git a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source new file mode 100644 index 0000000000..f498537d37 --- /dev/null +++ b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source @@ -0,0 +1,1233 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useI18n, LOCALES, type TFn } from "../i18n/shared"; +import { formatProviderDisplayName } from "../provider-icons"; +import { formatTokens } from "../format-tokens"; +import { hashLogConversationQuery } from "../log-conversation-id"; +import { statusCodeInfo } from "../status-codes"; +import { IconX } from "../icons"; +import { modelLabel } from "../model-display"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { useDataSurface } from "../data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; +import { EmptyState, Notice } from "../ui"; +import Debug from "./Debug"; +import { LogsFilterBar } from "./logs-filter-bar"; +import { logsClockAnchor, logsClockNow, type LogsClockAnchor } from "./logs-clock"; +import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiveLogFilters, type LogFilterState } from "./logs-filter"; + +import type { LogsTab } from "./logs-tab-keydown"; +import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; +import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; +import { speedLabel } from "./logs-speed-label"; +import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; +import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; +import type { LogSurface } from "./logs-surface-filter"; +import { + sanitizeLogEntryRouteDecision, + validCachedRouteDecision, +} from "./log-route-decision"; +import { mergeLogDelta, parseLogPollResponse } from "./log-poll"; + +function logsCacheKey(apiBase: string): string { + return `ocx.logs.list.v1:${apiBase}`; +} + +const EMPTY_LOGS: LogEntry[] = []; + +interface UsageBreakdown { + inputTokens: number; + outputTokens: number; + /** Absolute active-context snapshot after the response (stateful providers such as Kiro). */ + contextTotalTokens?: number; + totalTokens?: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + reasoningOutputTokens?: number; + estimated?: boolean; +} + +type LogUsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; + +type MetricUnavailableReason = + | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration" + | "price_unmatched" | "invalid_cache_breakdown" + | "invalid_usage" | "combo_attempt_unavailable" + | "ttft_missing" | "decode_window_too_short"; + +type CostEstimateReason = + | "usage_estimated" + | "cache_detail_missing" + | "expected_price_overlay" + | "provider_cost_overlay" + | "priority_lower_bound"; + +type TokPerSecondResult = + | { kind: "value"; value: number; estimated: boolean } + | { kind: "unavailable"; reason: MetricUnavailableReason }; + +interface MatchedPriceInfo { + provider: string; + modelId: string; + jawcodeProvider?: string; + source: "jawcode" | "expected" | "user"; + sourceRef?: string; + verifiedAt?: string; + status: "verified" | "verified-derived"; +} + +type CostResult = + | { + kind: "value"; + estimate: { + cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; + estimated: boolean; + priorityLowerBound?: boolean; + price?: MatchedPriceInfo; + attempts?: Array<{ ordinal: number; price: MatchedPriceInfo }>; + }; + estimateReasons: CostEstimateReason[]; + } + | { kind: "unavailable"; reason: MetricUnavailableReason }; + +interface LogDisplayMetrics { + tokPerSecond: TokPerSecondResult; + /** + * Estimated decode throughput (#4038). Optional because a row cached by an older build has no + * such field; absent renders nothing rather than an empty slot. + */ + decodeTokPerSecond?: TokPerSecondResult; + cost: CostResult; +} + +/** + * Recovery kinds recorded on a log attempt; rendered as localized labels in the logs + * detail dialog instead of raw wire values. + */ +type AttemptRecoveryKind = + | "transient-5xx" + | "connection-reset" + | "oauth-401" + | "key-429" + | "rate-limit-429" + | "anthropic-oauth-429" + | "image-413" + | "empty-completion"; + +interface LogAttempt { + ordinal: number; + provider: string; + model: string; + adapter: string; + status: number; + durationMs: number; + sendCount: number; + recoveryKinds: AttemptRecoveryKind[]; + usageStatus: LogUsageStatus; + inputTokenEstimate?: number; + usage?: UsageBreakdown; + totalTokens?: number; + errorCode?: string; + firstOutputMs?: number; + requestedEffort?: string; + effectiveEffort?: string; + reasoningWireField?: string; + reasoningWireValue?: string | number | boolean; + displayMetrics?: LogDisplayMetrics; +} + +export interface LogEntry { + requestId?: string; + timestamp: number; + model: string; + provider: string; + /** Pool/account label the turn was served under (e.g. "p83fa8d", "main"); absent when unattributed. */ + accountLogLabel?: string; + surface?: LogSurface; + conversationId?: string; + /** + * The original helper model, when Shadow Call Intercept rewrote this request. + * + * Present ONLY for an intercepted request. A helper request that was not intercepted -- + * interception off, no replacement model, or a slug the matcher does not recognize -- is + * indistinguishable here from ordinary traffic, which is why the filter below says + * "intercepted" rather than "helper". + */ + shadowCallRewrittenFrom?: string; + requestedEffort?: string; + effectiveEffort?: string; + reasoningWireField?: string; + reasoningWireValue?: string | number | boolean; + requestedServiceTier?: string; + requestedSpeedLabel?: string; + configuredServiceTier?: string; + configuredSpeedLabel?: string; + responseServiceTier?: string; + // #2455: qualifies responseServiceTier in the model tooltip — the echoed tier alone + // cannot say whether Fast was granted on a backend whose echo is not authoritative. + tierOutcome?: ModelTitleTierOutcome; + resolvedModel?: string; + modelSupportsServiceTier?: boolean; + status: number; + durationMs: number; + errorCode?: string; + upstreamError?: string; + usageStatus?: LogUsageStatus; + usage?: UsageBreakdown; + totalTokens?: number; + firstOutputMs?: number; + attempts?: LogAttempt[]; + displayMetrics?: LogDisplayMetrics; + /** Bounded route-decision trace (RI-01); absent for pre-trace rows. */ + routeDecision?: { + routeKind?: string; + profile?: { id?: string; revision?: string }; + selected?: { provider?: string; model?: string; reason?: string }; + candidates?: Array<{ provider?: string; model?: string; eligible?: boolean; exclusions?: Array<{ code?: string }> }>; + }; +} + +function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { + if (!Array.isArray(cached)) return null; + for (const entry of cached) { + if ( + !entry + || typeof entry !== "object" + || typeof entry.timestamp !== "number" + || typeof entry.model !== "string" + || typeof entry.provider !== "string" + || typeof entry.status !== "number" + || typeof entry.durationMs !== "number" + || (entry.shadowCallRewrittenFrom !== undefined && typeof entry.shadowCallRewrittenFrom !== "string") + || !validCachedRouteDecision(entry.routeDecision) + ) { + return null; + } + } + return cached; +} + +function displayTokenTotal(log: LogEntry): number | undefined { + if (!log.usage) return typeof log.totalTokens === "number" ? log.totalTokens : undefined; + // inputTokens is inclusive of cache read/write (canonical convention, devlog 070); + // never re-add cache detail. max() keeps legacy pre-070 rows honest. + const baseTotal = log.usage.inputTokens + log.usage.outputTokens; + const explicitTotal = log.usage.totalTokens ?? log.totalTokens; + return typeof explicitTotal === "number" ? Math.max(explicitTotal, baseTotal) : baseTotal; +} + +/** + * Row/detail display total that also honors an absolute context checkpoint. + * + * Stateful providers (Kiro) report per-attempt usage only, so their per-request total stays + * small while the real active context grows. `contextTotalTokens` is that absolute snapshot. + * + * NEVER SUM THIS ACROSS REQUESTS. A checkpoint is not a per-request delta: adding it up over + * a conversation counts the same context once per request and inflates aggregates wildly. + * Aggregate rollups must keep using `displayTokenTotal`. + */ +function displayContextTokenTotal(log: LogEntry): number | undefined { + const base = displayTokenTotal(log); + const contextTotal = log.usage?.contextTotalTokens; + if (typeof contextTotal !== "number") return base; + return Math.max(base ?? 0, contextTotal) || undefined; +} + +interface ReasoningLogFields { + requestedEffort?: string; + effectiveEffort?: string; + reasoningWireField?: string; + reasoningWireValue?: string | number | boolean; +} + +function effortLabel(log: ReasoningLogFields): string { + const requested = log.requestedEffort?.replace(/\s*->\s*/g, " → "); + const effective = log.effectiveEffort; + if (!requested) return effective ?? "-"; + // requestedEffort may already contain a cap/clamp chain (for example max->high). + // Only append the adapter result when it differs from that chain's terminal value. + if (!effective || requested === effective || requested.split(" → ").at(-1) === effective) return requested; + return `${requested} → ${effective}`; +} + +function reasoningWireLabel(log: ReasoningLogFields): string | undefined { + if (!log.reasoningWireField || log.reasoningWireValue === undefined) return undefined; + return `${log.reasoningWireField}=${log.reasoningWireValue}`; +} + +function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: string): string { + if (!result || result.kind === "unavailable" || !Number.isFinite(result.value) || result.value <= 0) return "\u2014"; + const digits = result.value >= 100 ? 0 : 1; + const value = new Intl.NumberFormat(localeTag, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(result.value); + return `${result.estimated ? "~" : ""}${value}`; +} + +const LOGS_POLL_INTERVAL_MS = 2000; +// Relative time filters must advance even when the polled snapshot is unchanged. Keep the +// refresh independent from the network poll so an active 15m/1h/24h window expires rows while +// the proxy is idle. +const LOGS_FILTER_CLOCK_INTERVAL_MS = 30_000; +const LOGS_POLL_BACKOFF_MAX_EXPONENT = 4; +/** Consecutive failed polls before a stale table is called out. */ +const STALE_POLL_FAILURE_LIMIT = 3; + +const METRIC_REASON_KEYS = { + usage_missing: "logs.detail.reason.usage_missing", + usage_unsupported: "logs.detail.reason.usage_unsupported", + output_missing: "logs.detail.reason.output_missing", + invalid_duration: "logs.detail.reason.invalid_duration", + price_unmatched: "logs.detail.reason.price_unmatched", + invalid_cache_breakdown: "logs.detail.reason.invalid_cache_breakdown", + invalid_usage: "logs.detail.reason.invalid_usage", + combo_attempt_unavailable: "logs.detail.reason.combo_attempt_unavailable", + ttft_missing: "logs.detail.reason.ttft_missing", + decode_window_too_short: "logs.detail.reason.decode_window_too_short", +} as const satisfies Record; + +const ESTIMATE_REASON_KEYS = { + usage_estimated: "logs.detail.estimate.usage_estimated", + cache_detail_missing: "logs.detail.estimate.cache_detail_missing", + expected_price_overlay: "logs.detail.estimate.expected_price_overlay", + provider_cost_overlay: "logs.detail.estimate.provider_cost_overlay", + priority_lower_bound: "logs.detail.estimate.priority_lower_bound", +} as const satisfies Record; + +/** + * i18n keys for every {@link AttemptRecoveryKind}, so the logs detail dialog renders a + * localized label instead of the raw wire value (e.g. `rate-limit-429`). + */ +const RECOVERY_KIND_KEYS = { + "transient-5xx": "logs.detail.attempt.recovery.transient5xx", + "connection-reset": "logs.detail.attempt.recovery.connectionReset", + "oauth-401": "logs.detail.attempt.recovery.oauth401", + "key-429": "logs.detail.attempt.recovery.key429", + "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", + "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", + "image-413": "logs.detail.attempt.recovery.image413", + "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", +} as const satisfies Record; + +/** Map a metric-unavailable reason to its i18n key. */ +function metricReasonKey(reason: MetricUnavailableReason) { + return METRIC_REASON_KEYS[reason]; +} + +/** Map a cost-estimate reason to its i18n key. */ +function estimateReasonKey(reason: CostEstimateReason) { + return ESTIMATE_REASON_KEYS[reason]; +} + +/** + * Map one attempt recovery kind to its i18n key for the logs detail dialog. + */ +function recoveryKindKey(kind: AttemptRecoveryKind) { + // A stale/malformed cached row can carry a kind outside the known set; fall back to a + // localized label instead of handing `t()` an undefined key. + return RECOVERY_KIND_KEYS[kind] ?? "logs.detail.attempt.recovery.unknown"; +} + +function verificationKey(status: MatchedPriceInfo["status"]): "logs.detail.verification.verified" | "logs.detail.verification.derived" { + return status === "verified" ? "logs.detail.verification.verified" : "logs.detail.verification.derived"; +} + +function statusColor(status: number): string { + if (status >= 200 && status < 300) return "var(--green)"; + if (status >= 400) return "var(--red)"; + return "var(--amber)"; +} + +/** Date and time as separate locale strings (no joining comma) for stacked table cells. */ +function formatLogDateParts(ts: number, localeTag?: string, timeZone?: string): { date: string; time: string } { + const zone = timeZone ? { timeZone } : undefined; + try { + return { + date: new Date(ts).toLocaleDateString(localeTag, zone), + time: new Date(ts).toLocaleTimeString(localeTag, zone), + }; + } catch { + // An IANA zone the browser's ICU build does not know throws RangeError. + return { + date: new Date(ts).toLocaleDateString(localeTag), + time: new Date(ts).toLocaleTimeString(localeTag), + }; + } +} + +function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string { + const { date, time } = formatLogDateParts(ts, localeTag, timeZone); + return `${date} ${time}`; +} + +function summarizeFilteredLogs(entries: LogEntry[]): { + requests: number; + totalTokens: number; + estimatedCostUsd: number; + priorityLowerBound: boolean; + unpricedRequests: number; + unmeteredRequests: number; +} { + let totalTokens = 0; + for (const entry of entries) { + const tokens = displayTokenTotal(entry); + if (tokens !== undefined) totalTokens += tokens; + } + return { + requests: entries.length, + totalTokens, + ...summarizeEstimatedCosts(entries), + }; +} + +export default function Logs({ apiBase }: { apiBase: string }) { + const { t, locale } = useI18n(); + const resourceKey = logsCacheKey(apiBase); + const cachedLogs = validCachedLogs(readSessionListCache(resourceKey)); + const [autoRefresh, setAutoRefresh] = useState(true); + const [failureStreak, setFailureStreak] = useState<{ error: unknown; count: number }>( + { error: null, count: 0 }, + ); + const [detail, setDetail] = useState(null); + const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); + const [filterClockNow, setFilterClockNow] = useState(() => Date.now()); + const filterClockRef = useRef<{ + key: string; anchor?: LogsClockAnchor; active: boolean; request: number; + }>({ key: resourceKey, active: false, request: 0 }); + const logPollRef = useRef<{ key: string; cursor: string | null; rows: LogEntry[] }>( + { key: resourceKey, cursor: null, rows: [] }, + ); + // Invalidate the old resource at commit, before passive resource-loader effects. + // A late body read must not mutate this page's clock, cache or retry state. + useLayoutEffect(() => { + const clock = { key: resourceKey, active: true, request: 0 }; + filterClockRef.current = clock; + // Cached display rows never establish a cursor, including A -> B -> A. + logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; + setFilterClockNow(Date.now()); + return () => { clock.active = false; }; + }, [resourceKey]); + const readFilterClockNow = useCallback(() => { + const clock = filterClockRef.current; + return logsClockNow(clock.key === resourceKey ? clock.anchor : undefined, performance.now(), Date.now()); + }, [resourceKey]); + const scrollContainerRef = useRef(null); + const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( + { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, + ); + const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; + // The proxy's own zone, so timestamps read the same as the server's logs rather than being + // silently shifted into the viewer's zone (#725). Fetched once: it cannot change while the + // page is open, so it must not join the 2s log poll. Undefined until it arrives, which + // formats browser-local exactly as before. + const [serverTimeZone, setServerTimeZone] = useState(); + useEffect(() => { + const controller = new AbortController(); + // Abort already rejects the in-flight fetch, but the flag keeps the guarantee + // local: the setter is visibly gated without having to reason about whether + // the abort propagates through the body read. + let cancelled = false; + fetch(`${apiBase}/api/settings`, { signal: controller.signal }) + .then(res => (res.ok ? res.json() as Promise<{ timeZone?: unknown }> : null)) + .then(body => { + if (cancelled || !body) return; + if (typeof body.timeZone === "string" && body.timeZone.trim()) { + setServerTimeZone(body.timeZone.trim()); + } + }) + .catch(() => { + // Offline or an older proxy without the field: keep browser-local formatting. + }); + return () => { + cancelled = true; + controller.abort(); + }; + }, [apiBase]); + // Opaque log labels → human attribution (email masked per proxy privacy settings). + // Fetched once per page: labels are stable for the lifetime of an account. + const [accountLabels, setAccountLabels] = useState>(new Map()); + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + fetch(`${apiBase}/api/account-labels`, { signal: controller.signal }) + .then(res => (res.ok ? res.json() as Promise<{ labels?: Array<{ label?: unknown; email?: unknown; plan?: unknown }> }> : null)) + .then(body => { + if (!body?.labels) return; + const map = new Map(); + for (const row of body.labels) { + if (typeof row.label !== "string" || !row.label) continue; + const parts: string[] = []; + if (typeof row.email === "string" && row.email) parts.push(row.email); + if (typeof row.plan === "string" && row.plan) parts.push(row.plan); + if (parts.length > 0) map.set(row.label, parts.join(" · ")); + } + setAccountLabels(map); + }) + .catch(() => { + // Older proxy without the endpoint: fall back to the raw opaque labels. + }); + return () => { + cancelled = true; + controller.abort(); + }; + }, [apiBase]); + // The hash is the source of truth for the active tab (#logs vs #logs/debug), + // so refresh/bookmark/back-forward keep the tab choice. + const [tab, setTab] = useState(readTabFromHash); + // Lazy-mount Debug on first visit, then keep it mounted so switch toggles + // and Logs↔Debug hops do not remount (avoids settings/log refetch storms). + const [debugMounted, setDebugMounted] = useState(() => readTabFromHash() === "debug"); + + useEffect(() => { + const onHash = () => setTab(readTabFromHash()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + useEffect(() => { + if (tab === "debug") setDebugMounted(true); + }, [tab]); + + const selectTab = selectLogsTab; + + const loadLogs = useCallback(async (signal: AbortSignal): Promise => { + const clock = filterClockRef.current; + if (signal.aborted || !clock.active || clock.key !== resourceKey) { + throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + } + const request = ++clock.request; + const isCurrent = () => !signal.aborted && clock.active + && filterClockRef.current === clock && clock.request === request; + let retry = logRetryRef.current; + if (retry.key !== resourceKey) { + retry = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; + logRetryRef.current = retry; + } + if (retry.failures > 0 && Date.now() < retry.nextAttemptAt) throw retry.error; + const poll = logPollRef.current; + const cursor = poll.key === resourceKey ? poll.cursor : null; + const url = `${apiBase}/api/logs?limit=2000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; + try { + const res = await fetch(url, { signal }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); + const body: unknown = await res.json(); + const receivedAt = performance.now(); + const parsed = parseLogPollResponse(body); + const incoming = parsed.rows.map(sanitizeLogEntryRouteDecision); + const next = cursor && parsed.cursor && !parsed.reset + ? mergeLogDelta(poll.rows, incoming) : incoming; + // The resource-store generation guard runs only after this loader returns. + // Guard these local side effects here as fetch/body readers may ignore abort. + if (!isCurrent()) throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + logPollRef.current = { key: resourceKey, cursor: parsed.cursor, rows: next }; + // Reconcile when the accepted snapshot changes, using the latest user state + // rather than filters captured when the request started. Persist disappearance + // as All so a later ring cannot resurrect a cleared selection. + const options = extractLogFilterOptions(next); + setFilters(previous => { + const model = previous.model.trim().toLowerCase(); + const provider = previous.provider.trim().toLowerCase(); + const nextModel = model + ? options.models.find(option => option.trim().toLowerCase() === model) ?? "" + : ""; + const nextProvider = provider + ? options.providers.find(option => option.trim().toLowerCase() === provider) ?? "" + : ""; + if (previous.model === nextModel && previous.provider === nextProvider) return previous; + return { ...previous, model: nextModel, provider: nextProvider }; + }); + const sample = logsClockAnchor(parsed.generatedAt, receivedAt); + if (sample) clock.anchor = sample; + setFilterClockNow(logsClockNow(clock.anchor, receivedAt, Date.now())); + logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; + writeSessionListCache(resourceKey, next); + return next; + } catch (error) { + if (!isCurrent()) throw error; + const normalized = error ?? new Error("log request failed"); + const failures = retry.failures + 1; + const backoffMs = LOGS_POLL_INTERVAL_MS * (2 ** Math.min( + failures, + LOGS_POLL_BACKOFF_MAX_EXPONENT, + )); + logRetryRef.current = { key: resourceKey, failures, nextAttemptAt: Date.now() + backoffMs, error: normalized }; + throw normalized; + } + }, [apiBase, resourceKey]); + + // The resource layer owns the request and the 2s base poll. loadLogs backs off actual network + // attempts after failures while preserving those shared scheduler ticks and held rows. + const logsResource = useDataSurface( + resourceKey, + [apiBase], + loadLogs, + { + isEmpty: rows => rows.length === 0, + enabled: tab === "logs", + pollMs: autoRefresh ? LOGS_POLL_INTERVAL_MS : undefined, + initialData: cachedLogs ?? undefined, + }, + ); + const logsState = logsResource.state; + const logs = logsState.data ?? cachedLogs ?? EMPTY_LOGS; + const fetchLogs = logsResource.refresh; + const retryLogs = useCallback(() => { + logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; + logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; + fetchLogs({ forceLoading: true }); + }, [fetchLogs, resourceKey]); + + // A single failed tick on a two-second poll is noise, but an outage that never recovers must not + // leave the user reading stale rows as if they were current. Count consecutive failures and speak + // up once it is clearly not transient. + const settledFailure = !logsResource.refreshing && logsState.showError; + const settledSuccess = !logsResource.refreshing && !logsState.showError && logsState.data !== undefined; + // Derived from the settlement itself, so there is no second copy of this state to keep + // in sync and no frame painted with a stale banner. `streak` counts CONSECUTIVE failed + // settlements: it is stored keyed by the error identity that produced it, so repeated + // renders of the same failure do not inflate the count and a success clears it. + if (settledSuccess && failureStreak.count !== 0) { + setFailureStreak({ error: null, count: 0 }); + } else if (settledFailure && failureStreak.error !== logsState.error) { + setFailureStreak(previous => ({ error: logsState.error, count: previous.count + 1 })); + } + // Auto-refresh off: one settled failure is enough — there is no next poll to recover quietly. + const pollFailing = + failureStreak.count >= STALE_POLL_FAILURE_LIMIT + || (!autoRefresh && settledFailure); + + const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; + const conversationQuery = filters.conversationId.trim(); + + useEffect(() => { + if (filters.timeWindow === "all" || tab !== "logs") return; + setFilterClockNow(readFilterClockNow()); + const timer = window.setInterval(() => setFilterClockNow(readFilterClockNow()), LOGS_FILTER_CLOCK_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [filters.timeWindow, tab, readFilterClockNow]); + + useEffect(() => { + let cancelled = false; + if (!conversationQuery) { + setFilters(prev => prev.conversationQueryHash === undefined ? prev : { ...prev, conversationQueryHash: undefined }); + return; + } + void hashLogConversationQuery(conversationQuery).then(hash => { + if (!cancelled) setFilters(prev => prev.conversationQueryHash === hash ? prev : { ...prev, conversationQueryHash: hash }); + }); + return () => { cancelled = true; }; + }, [conversationQuery]); + + const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); + const activeFilters = hasActiveLogFilters(filters); + const filteredLogs = useMemo(() => filterLogs(logs, filters, filterClockNow), [logs, filters, filterClockNow]); + const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; + + // TanStack Virtual returns unstable function identities; React Compiler skips this call. + // eslint-disable-next-line react-hooks/incompatible-library -- known useVirtualizer limitation + const rowVirtualizer = useVirtualizer({ + count: filteredLogs.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 92, + overscan: 15, + getItemKey: index => { + const log = filteredLogs[filteredLogs.length - 1 - index]!; + return log.requestId ?? `${log.timestamp}:${log.model}:${log.provider}`; + }, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; + const paddingBottom = virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end + : 0; + + return ( +
+
+

{t("nav.logs")}

+ {tab === "logs" && ( + + )} +
+
+ + +
+ + {debugMounted && ( + + )} + + +
+ ); +} + +function useModalDialog(open: boolean) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + if (open && !el.open) el.showModal(); + else if (!open && el.open) el.close(); + }, [open]); + return ref; +} + +function LogDetailDialog({ + detail, detailInfo, localeCode, localeTag, serverTimeZone, t, onClose, onFilterConversation, +}: { + detail: LogEntry; + detailInfo: ReturnType | null; + localeCode: string; + localeTag?: string; + serverTimeZone?: string; + t: TFn; + onClose: () => void; + onFilterConversation?: (conversationId: string) => void; +}) { + const dialogRef = useModalDialog(true); + const [copied, setCopied] = useState(false); + const tokenSplit = cacheSplit(detail); + const cost = detail.displayMetrics?.cost; + const reasoningWire = reasoningWireLabel(detail); + + const copyRequestId = async () => { + if (!detail.requestId) return; + try { + await navigator.clipboard.writeText(detail.requestId); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch { + // copy failure must not break the dialog + } + }; + + return ( + { e.preventDefault(); onClose(); }} + > + + + {detailInfo &&

{detailInfo.description}

} + +
+

{t("logs.detail.section.basic")}

+
+ {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} + {t("logs.col.request")} + + {detail.requestId ?? "\u2014"} + {detail.requestId && ( + + )} + + {detail.conversationId && ( + <> + {t("logs.detail.conversation")} + + {detail.conversationId} + {onFilterConversation && ( + + )} + + + )} + {t("logs.col.model")}{modelLabel(detail.resolvedModel ?? detail.model)} + {t("logs.col.provider")}{formatProviderDisplayName(detail.provider, t)} + {(detail.requestedEffort || detail.effectiveEffort) && ( + <>{t("logs.col.effort")}{effortLabel(detail)}{reasoningWire ? ` (${reasoningWire})` : ""} + )} + {detail.errorCode && (<>{t("logs.col.error")}{detail.errorCode})} + {detail.upstreamError && (<>{t("logs.col.upstreamReason")}{detail.upstreamError})} +
+
+ +
+

{t("logs.detail.route.section")}

+ {detail.routeDecision ? ( +
+ {t("logs.detail.route.kind")}{detail.routeDecision.routeKind ?? "–"} + {detail.routeDecision.profile?.id && ( + <>{t("logs.detail.route.profile")} + {detail.routeDecision.profile.id} ({detail.routeDecision.profile.revision}) + )} + {detail.routeDecision.selected?.provider && ( + <>{t("logs.detail.route.selected")} + + {detail.routeDecision.selected.provider}/{detail.routeDecision.selected.model} + {detail.routeDecision.selected.reason ? ` — ${detail.routeDecision.selected.reason}` : ""} + + )} + {t("logs.detail.route.candidates")} + + {(detail.routeDecision.candidates ?? []).map(candidate => { + const provider = typeof candidate.provider === "string" && candidate.provider.length > 0 + ? candidate.provider + : "–"; + const model = typeof candidate.model === "string" && candidate.model.length > 0 + ? candidate.model + : "–"; + const mark = candidate.eligible === true + ? " ✓" + : candidate.eligible === false + ? " ✗" + : " ?"; + return `${provider}/${model}${mark}`; + }).join(" ") || "–"} + +
+ ) : ( +

{t("logs.detail.route.unknown")}

+ )} +
+ +
+

{t("logs.detail.section.performance")}

+
+ {t("logs.col.duration")}{detail.durationMs}ms + {t("logs.col.tokPerSec")}{formatTokPerSecond(detail.displayMetrics?.tokPerSecond, localeTag)} + {detail.displayMetrics?.decodeTokPerSecond?.kind === "value" && ( + <>{t("logs.detail.decodeTokPerSec")}{formatTokPerSecond(detail.displayMetrics.decodeTokPerSecond, localeTag)} + )} + {detail.firstOutputMs !== undefined && ( + <>{t("logs.detail.ttft")}{detail.firstOutputMs}ms + )} +
+ {detail.displayMetrics?.tokPerSecond.kind === "unavailable" && ( +

{t(metricReasonKey(detail.displayMetrics.tokPerSecond.reason))}

+ )} + {detail.displayMetrics?.decodeTokPerSecond?.kind === "unavailable" && ( +

+ {t("logs.detail.decodeTokPerSec")}: {t(metricReasonKey(detail.displayMetrics.decodeTokPerSecond.reason))} +

+ )} +
+ +
+

{t("logs.detail.section.cost")}

+

{t("usage.cost.disclaimer")}

+ {cost?.kind === "value" ? ( + <> +
+ {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, t, localeTag, cost.estimate.priorityLowerBound)} + {cost.estimate.price && ( + <> + {t("logs.detail.matchedKey")} + {cost.estimate.price.jawcodeProvider ?? cost.estimate.price.provider}/{cost.estimate.price.modelId} + {t("logs.detail.priceSource")} + {t(`logs.detail.source.${cost.estimate.price.source}`)} · {t(verificationKey(cost.estimate.price.status))} + + )} +
+ {cost.estimateReasons.length > 0 && ( +
    + {cost.estimateReasons.map(reason =>
  • {t(estimateReasonKey(reason))}
  • )} +
+ )} + + ) : ( +
+ {t("logs.detail.costTotal")}{t("logs.cost.unavailable")} + {t("logs.detail.unavailableReason")} + {cost?.kind === "unavailable" ? t(metricReasonKey(cost.reason)) : t("logs.detail.reason.usage_missing")} +
+ )} +
+ + {detail.attempts?.length ? ( +
+

{t("logs.detail.section.attempts")}

+

{t("logs.detail.attempt.e2eNote")}

+
+ + + + + + + + + + {detail.attempts.toSorted((a, b) => a.ordinal - b.ordinal).map(attempt => { + const attemptCost = attempt.displayMetrics?.cost; + const attemptReasoningWire = reasoningWireLabel(attempt); + const matched = attemptCost?.kind === "value" ? attemptCost.estimate.price : undefined; + const reason = attempt.errorCode + ?? (attempt.recoveryKinds.length + ? attempt.recoveryKinds.map(kind => t(recoveryKindKey(kind))).join(", ") + : undefined) + ?? (attemptCost?.kind === "unavailable" ? t(metricReasonKey(attemptCost.reason)) : t("logs.detail.attempt.completed")); + return ( + + + + + + + + + ); + })} +
#{t("logs.detail.attempt.target")}{t("logs.col.duration")}{t("logs.col.tokPerSec")}{t("logs.col.estimatedCost")}{t("logs.detail.attempt.reason")}
{attempt.ordinal} + {formatProviderDisplayName(attempt.provider, t)}
+ {attempt.model} + {(attempt.requestedEffort || attempt.effectiveEffort) && ( + <> +
+ + {effortLabel(attempt)}{attemptReasoningWire ? ` (${attemptReasoningWire})` : ""} + + + )} + {matched && ( + <> +
+ + {matched.jawcodeProvider ?? matched.provider}/{matched.modelId} · {t(`logs.detail.source.${matched.source}`)} · {t(verificationKey(matched.status))} + + + )} +
{attempt.durationMs}ms + {formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} + {/* #4038: the DTO already carries a per-attempt decode rate measured on + that attempt's own TTFT, so the attempt table stacks it the same way + the parent row and the list do. */} + {attempt.displayMetrics?.decodeTokPerSecond?.kind === "value" && ( + + {formatTokPerSecond(attempt.displayMetrics.decodeTokPerSecond, localeTag)} + + )} + {formatEstimatedUsd(attemptCost, t, localeTag)}{reason}
+
+
+ ) : null} + +
+

{t("logs.detail.section.usage")}

+
+ {t("logs.tokens.input")}{detail.usage ? formatTokens(detail.usage.inputTokens, localeCode) : "\u2014"} + {t("logs.tokens.output")}{detail.usage ? formatTokens(detail.usage.outputTokens, localeCode) : "\u2014"} + {t("logs.tokens.cacheRead")}{tokenSplit.read !== undefined ? formatTokens(tokenSplit.read, localeCode) : "\u2014"} + {t("logs.tokens.cacheWrite")}{tokenSplit.write !== undefined ? formatTokens(tokenSplit.write, localeCode) : "\u2014"} + {t("logs.tokens.reasoning")}{detail.usage?.reasoningOutputTokens !== undefined ? formatTokens(detail.usage.reasoningOutputTokens, localeCode) : "\u2014"} + {t("logs.detail.totalTokens")}{displayContextTokenTotal(detail) !== undefined ? formatTokens(displayContextTokenTotal(detail)!, localeCode) : "\u2014"} + {detail.usage?.contextTotalTokens !== undefined && ( + <> + {t("logs.tokens.contextTotal")} + {formatTokens(detail.usage.contextTotalTokens, localeCode)} + + )} +
+ {detail.usageStatus === "estimated" && ( +

{t("logs.tokens.estimatedNote")}

+ )} +
+ +
+ {t("logs.detailRaw")} +
{JSON.stringify(detail, null, 2)}
+
+ +
+ ); +} diff --git a/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json b/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json new file mode 100644 index 0000000000..ed5301b7d3 --- /dev/null +++ b/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-12T18:35:37.508Z", + "sessionId": "sess_866f3f9a-3097-4f86-af44-a594358fdc75", + "event": "PostToolUse", + "toolName": "Edit", + "file": "src/pages/Logs.tsx", + "outcome": "clear", + "coverage": "complete", + "findingCount": 0, + "durationMs": 5, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/src/adapters/zcode-start-plan/captcha-solver.ts b/src/adapters/zcode-start-plan/captcha-solver.ts index cacd5f457e..3f6ec77baa 100644 --- a/src/adapters/zcode-start-plan/captcha-solver.ts +++ b/src/adapters/zcode-start-plan/captcha-solver.ts @@ -106,14 +106,14 @@ function syncFetchBlocking(url: string, init: Record, timeoutMs return { status: i32[1], statusText, headers, setCookie, body }; } catch (err: any) { // A crashed worker must not poison later solves — reset it. - try { _syncFetchWorker?.terminate(); } catch {} + try { _syncFetchWorker?.terminate(); } catch { /* best-effort: continue */ } _syncFetchWorker = null; return { error: `sync fetch error: ${err?.message ?? err}` }; } } function shutdownSyncFetchWorker(): void { - try { _syncFetchWorker?.terminate(); } catch {} + try { _syncFetchWorker?.terminate(); } catch { /* best-effort: continue */ } _syncFetchWorker = null; } @@ -129,7 +129,7 @@ const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; if (proxyUrl) { try { setGlobalDispatcher(new ProxyAgent(proxyUrl)); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // ── Globals shared across solves ──────────────────────────────────────────── @@ -155,10 +155,10 @@ function noteStallAndMaybeEvict(peUrl) { } if (n >= 2) { _memCdnCache.delete(peUrl); - try { fs.unlinkSync(diskPathFor(peUrl)); } catch (_) {} + try { fs.unlinkSync(diskPathFor(peUrl)); } catch (_) { /* best-effort: continue */ } _stallCounts.delete(peUrl); } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // ── Fingerprint ───────────────────────────────────────────────────────────── @@ -208,7 +208,7 @@ function getCachedBody(url) { _memCdnCache.set(url, body); return body; } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } return null; } @@ -257,9 +257,9 @@ function injectRequestHeaders(request) { if (crossOrigin || (method !== "GET" && method !== "HEAD")) { origin = "https://zcode.z.ai"; } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } if (origin) h.set("origin", origin); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } function cookieHeader(request, window, browserFrame) { @@ -271,7 +271,7 @@ function cookieHeader(request, window, browserFrame) { if (cookies.length > 0) { return cookies.map((c) => `${c.name}=${c.value}`).join("; "); } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } return null; } @@ -305,11 +305,11 @@ function storeSetCookies(res, url) { } try { cookieContainer.addCookies([cookie]); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } } } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // ── The interceptor: replaces happy-dom's network layer completely ───────── @@ -329,7 +329,7 @@ function makeInterceptor(bypassPeCache = false) { } catch (parseErr) { process.stderr.write(`[cache-bad-js] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); _memCdnCache.delete(url); - try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + try { fs.unlinkSync(diskPathFor(url)); } catch (_) { /* best-effort: continue */ } body = null; } } @@ -338,7 +338,7 @@ function makeInterceptor(bypassPeCache = false) { // the async fetch path handled by happy-dom. if (body) { if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { - try { w.__lastPeUrl = url; } catch (_) {} + try { w.__lastPeUrl = url; } catch (_) { /* best-effort: continue */ } } return new w.Response(Buffer.from(body), { status: 200, @@ -365,14 +365,14 @@ function makeInterceptor(bypassPeCache = false) { hasBody = true; } } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } const res = await fetch(url, init); const buf = Buffer.from(await res.arrayBuffer()); storeSetCookies(res, url); if (_DEBUG && /captcha-open|verify\.|device\.saf|cloudauth-device|upload\./i.test(url) && buf.length && buf.length < 4096) { try { process.stderr.write(`[xhr-body] ${request.method} ${bs.hostname}${bs.pathname}-> ${res.status} ${buf.toString("utf8").slice(0, 1200)}\n`); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } const headers = {}; const ct = res.headers.get("content-type"); @@ -405,7 +405,7 @@ function makeInterceptor(bypassPeCache = false) { } catch (parseErr) { process.stderr.write(`[cache-bad-js:sync] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); _memCdnCache.delete(url); - try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + try { fs.unlinkSync(diskPathFor(url)); } catch (_) { /* best-effort: continue */ } body = null; } } @@ -415,7 +415,7 @@ function makeInterceptor(bypassPeCache = false) { } if (body) { if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { - try { w.__lastPeUrl = url; } catch (_) {} + try { w.__lastPeUrl = url; } catch (_) { /* best-effort: continue */ } } return { status: 200, @@ -442,7 +442,7 @@ function makeInterceptor(bypassPeCache = false) { const ab = request.body; if (ab && (ab as any).byteLength > 0) init.body = ab; } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } const res = syncFetchBlocking(url, init as any) as any; if (res.error) { process.stderr.write(`[sync-xhr-err] ${url}: ${res.error}\n`); @@ -473,9 +473,9 @@ function makeInterceptor(bypassPeCache = false) { if (k === "secure") cookie.secure = true; if (k === "samesite") cookie.sameSite = kv[1]; } - try { cookieContainer.addCookies([cookie]); } catch (_) {} + try { cookieContainer.addCookies([cookie]); } catch (_) { /* best-effort: continue */ } } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } const hdrs: Record = {}; for (const [k, v] of Object.entries(res.headers || {})) hdrs[k] = String(v); // Sync interceptor contract: PLAIN OBJECT with Buffer body (happy-dom's @@ -562,7 +562,7 @@ function removeGuestScope(w) { const id = w && w.__capScopeId; const root = globalThis[GUEST_SCOPE_ROOT]; if (id && root) delete root[id]; - } catch {} + } catch { /* best-effort: continue */ } } /** @@ -591,7 +591,7 @@ function makeScopedFunction(w) { configurable: true, writable: true, }); - } catch {} + } catch { /* best-effort: continue */ } return Scoped; } @@ -673,7 +673,7 @@ function installNativeToString(w) { configurable: true, writable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } }; const seen = new w.Set(); const maskObj = (obj, depth) => { @@ -691,7 +691,7 @@ function installNativeToString(w) { const ctorName = obj.constructor.name; if (/^(WriteStream|ReadStream|Socket|Process|Timeout|Immediate)$/.test(ctorName)) return; } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } if (seen.has(obj)) return; try { seen.add(obj); @@ -726,14 +726,14 @@ function installNativeToString(w) { // sink each probe surfaced as an unhandledRejection during every // solve — noise that buried real diagnostics. else if (v && typeof v.then === "function") v.catch(() => {}); - } catch {} + } catch { /* best-effort: continue */ } } if (depth < 3) { try { const v = desc.value; if (v && (typeof v === "function" || typeof v === "object")) maskObj(v, depth + 1); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } } }; @@ -754,7 +754,7 @@ function installNativeToString(w) { for (const t of targets) { try { maskObj(t, 0); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } } @@ -766,16 +766,16 @@ const GUEST_EVAL_PATCH = ` get() { return true; }, configurable: true }); - } catch (e) {} + } catch (e) { /* best-effort: continue */ } try { if (window.HTMLDocument) { Object.defineProperty(window.HTMLDocument, "name", { value: "HTMLDocument", configurable: true }); Object.defineProperty(window.HTMLDocument.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); } - } catch (e) {} + } catch (e) { /* best-effort: continue */ } try { Object.defineProperty(window.Document.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); - } catch (e) {} + } catch (e) { /* best-effort: continue */ } // Guest errors are RECORDED, not printed: the Aliyun/FeiLin SDKs throw // benign uncaught TypeErrors inside happy-dom on every solve (imperfect DOM // emulation) while the solve still succeeds — printing them flooded the @@ -796,19 +796,19 @@ const GUEST_EVAL_PATCH = ` if (window.__capErrs.length > 8) window.__capErrs.shift(); } if (__capDebug) console.error("[" + kind + "]", m, s); - } catch (e2) {} + } catch (e2) { /* best-effort: continue */ } } try { window.addEventListener("unhandledrejection", function(e) { var r = e && e.reason; __capRecord("UH-REASON", (r && r.message) || typeof r, r && r.stack); }); - } catch (e) {} + } catch (e) { /* best-effort: continue */ } try { window.addEventListener("error", function(e) { __capRecord("WINDOW-ERROR", e && e.message, e && e.error && e.error.stack); }); - } catch (e) {} + } catch (e) { /* best-effort: continue */ } // Pass-through eval/Function wrappers kept from the removed parse-fail // dump instrumentation (name/prototype masking preserved). try { @@ -818,7 +818,7 @@ const GUEST_EVAL_PATCH = ` return _origEval2.call(window, code); }; } - } catch (e) {} + } catch (e) { /* best-effort: continue */ } try { var _of = window.Function; if (_of) { @@ -826,10 +826,10 @@ const GUEST_EVAL_PATCH = ` return _of.apply(this, Array.prototype.slice.call(arguments)); }; _WF.prototype = _of.prototype; - try { Object.defineProperty(_WF, "name", { value: "Function", configurable: true }); } catch (e) {} + try { Object.defineProperty(_WF, "name", { value: "Function", configurable: true }); } catch (e) { /* best-effort: continue */ } window.Function = _WF; } - } catch (e) {} + } catch (e) { /* best-effort: continue */ } })(); `; @@ -866,11 +866,11 @@ function applyPolyfills(w) { if (typeof w.confirm !== "function") w.confirm = () => false; if (typeof w.open !== "function") w.open = () => null; if (typeof w.close !== "function") w.close = () => {}; - try { Object.defineProperty(w, "alert", { value: w.alert, configurable: true, writable: true }); } catch (_) {} - try { Object.defineProperty(w, "prompt", { value: w.prompt, configurable: true, writable: true }); } catch (_) {} - try { Object.defineProperty(w, "confirm", { value: w.confirm, configurable: true, writable: true }); } catch (_) {} - try { Object.defineProperty(w, "open", { value: w.open, configurable: true, writable: true }); } catch (_) {} - try { Object.defineProperty(w, "close", { value: w.close, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "alert", { value: w.alert, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } + try { Object.defineProperty(w, "prompt", { value: w.prompt, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } + try { Object.defineProperty(w, "confirm", { value: w.confirm, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } + try { Object.defineProperty(w, "open", { value: w.open, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } + try { Object.defineProperty(w, "close", { value: w.close, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } // happy-dom lacks browser globals that FeiLin / the pe risk engine probe. // A missing one throws ReferenceError inside the VM machine → breaks the @@ -884,12 +884,12 @@ function applyPolyfills(w) { find: () => false, }; for (const [k, v] of Object.entries(extraGlobals)) { - try { Object.defineProperty(w, k, { value: v, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, k, { value: v, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } } // happy-dom's own open()/close() are destructive (close() tears the window // down); the risk engine probes them → neutralize. - try { Object.defineProperty(w, "open", { value: () => null, configurable: true, writable: true }); } catch (_) {} - try { Object.defineProperty(w, "close", { value: () => {}, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "open", { value: () => null, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } + try { Object.defineProperty(w, "close", { value: () => {}, configurable: true, writable: true }); } catch (_) { /* best-effort: continue */ } if (!w.Option) { w.Option = class { @@ -1084,7 +1084,7 @@ function applyPolyfills(w) { try { const ctx = nativeGetContext.call(this, type, ...rest); if (ctx) return ctx; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } return make2DStub(this); }; @@ -1190,7 +1190,7 @@ function applyPolyfills(w) { proto.toDataURL = function (...a) { try { if (nativeToDataURL) return nativeToDataURL.apply(this, a); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } return fp.canvasImage; }; if (typeof proto.toBlob !== "function") { @@ -1294,7 +1294,7 @@ function applyPolyfills(w) { value: "visible", configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } if (!w.document.fonts) { w.document.fonts = { @@ -1373,7 +1373,7 @@ function applyPolyfills(w) { for (const [k, v] of Object.entries(navPatch)) { try { Object.defineProperty(nav, k, { value: v, configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // polyfill navigator sub-objects that happy-dom lacks @@ -1389,7 +1389,7 @@ function applyPolyfills(w) { w.NetworkInformation = NetInfo; try { Object.defineProperty(nav, "connection", { value: makeNS(NetInfo.prototype), configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } if (!nav.userAgentData) { const UAData = function () {}; @@ -1420,7 +1420,7 @@ function applyPolyfills(w) { }; try { Object.defineProperty(nav, "userAgentData", { value: makeNS(UAData.prototype), configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } if (!w.Permissions) { const Perms = () => {}; @@ -1432,14 +1432,14 @@ function applyPolyfills(w) { } try { if (!nav.permissions) Object.defineProperty(nav, "permissions", { value: makeNS(w.Permissions.prototype), configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.clipboard) Object.defineProperty(nav, "clipboard", { value: makeNS({ readText: () => Promise.resolve(""), writeText: () => Promise.resolve() }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.geolocation) Object.defineProperty(nav, "geolocation", { @@ -1450,35 +1450,35 @@ function applyPolyfills(w) { }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.credentials) Object.defineProperty(nav, "credentials", { value: makeNS({ get: () => Promise.resolve(null), create: () => Promise.resolve(null), store: () => Promise.resolve(), preventSilentAccess: () => Promise.resolve() }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.storage) Object.defineProperty(nav, "storage", { value: makeNS({ estimate: () => Promise.resolve({ quota: 1e8, usage: 0 }), persisted: () => Promise.resolve(false), persist: () => Promise.resolve(false) }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.usb) Object.defineProperty(nav, "usb", { value: makeNS({ getDevices: () => Promise.resolve([]), requestDevice: () => Promise.reject(new Error("no devices")) }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (!nav.mediaDevices) Object.defineProperty(nav, "mediaDevices", { value: makeNS({ enumerateDevices: () => Promise.resolve([]), getUserMedia: () => Promise.reject(new Error("NotAllowedError")) }), configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } // screen const screenPatch = { @@ -1495,7 +1495,7 @@ function applyPolyfills(w) { for (const [k, v] of Object.entries(screenPatch)) { try { Object.defineProperty(w.screen, k, { get: () => v, configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } w.outerWidth = fp.screen.w; @@ -1557,7 +1557,7 @@ function simulateBehavior(w, durationMs = 600) { const ev = new Ctor(type, { bubbles: true, cancelable: true, view: w, ...opts }); document.dispatchEvent(ev); if (document.body) document.body.dispatchEvent(ev); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } }; let x = 140 + Math.random() * 30; let y = 110 + Math.random() * 20; @@ -1590,7 +1590,7 @@ function simulateBehavior(w, durationMs = 600) { fire("click", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0 }); try { fire("keyup", KeyboardEvent, { key: "a", code: "KeyA", keyCode: 65, which: 65 }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } }; moveStep(); @@ -1603,7 +1603,7 @@ function waitFor(cond, timeoutMs = 15_000, intervalMs = 40) { let ok = false; try { ok = cond(); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } if (ok) { clearInterval(timer); res(); @@ -1634,7 +1634,7 @@ async function createDom(region, prefix) { }); cookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; _cookieCache = { cookies, ts: Date.now() }; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } const interceptor = makeInterceptor(_bypassPeCacheOnce); @@ -1648,7 +1648,7 @@ async function createDom(region, prefix) { try { const r = reason && reason.stack ? reason.stack : String(reason); process.stderr.write(`[host-unhandledRejection] ${typeof reason} ${JSON.stringify(reason).slice(0, 200)} ${r}\n`); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } }); // Guest scripts (rotated pe/FeiLin bundles) can throw synchronous errors // that surface as uncaughtExceptions. Without a handler, happy-dom's @@ -1658,7 +1658,7 @@ async function createDom(region, prefix) { try { const msg = err && err.message ? err.message : String(err); process.stderr.write(`[captcha-guest-uncaught] ${msg}\n`); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } }); } // Guest console is silent unless CAPTCHA_DEBUG — piping every SDK log to @@ -1721,7 +1721,7 @@ async function createDom(region, prefix) { if (k === "samesite") cookie.sameSite = kv[1]; } browserFrame.page.context.cookieContainer.addCookies([cookie]); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } const visitorId = crypto.randomUUID(); @@ -1734,7 +1734,7 @@ async function createDom(region, prefix) { for (const c of pre) { try { browserFrame.page.context.cookieContainer.addCookies([{ ...c, url: "https://zcode.z.ai", path: "/" }]); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // Apply polyfills + masking BEFORE the SDK script runs. @@ -1913,14 +1913,14 @@ function installGlobalWindowAlias(g, w, tombstoneMs?) { // flowing to the restore path. if (d && d.get && _aliasGetters.has(d.get)) continue; if (d) saved[prop] = d; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } for (const prop of ["window", "self", "top", "parent", "__capWindowFor"]) { try { const d = Object.getOwnPropertyDescriptor(g, prop); if (d && d.get && _aliasGetters.has(d.get)) continue; if (d && !saved[prop]) saved[prop] = d; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } _savedHostGlobalDescriptors = saved; } @@ -1932,11 +1932,11 @@ function installGlobalWindowAlias(g, w, tombstoneMs?) { Object.defineProperty(g, prop, { get: getter, set(v) { - try { w[prop] = v; } catch (_) {} + try { w[prop] = v; } catch (_) { /* best-effort: continue */ } }, configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // w.window/self may not exist as own props on this happy-dom build for (const prop of ["window", "self", "top", "parent"]) { @@ -1944,7 +1944,7 @@ function installGlobalWindowAlias(g, w, tombstoneMs?) { const getter = function () { return w; }; _aliasGetters.add(getter); Object.defineProperty(g, prop, { get: getter, configurable: true }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } // Timers are deliberately NOT aliased: globalThis keeps Bun's pristine // functions so node:_http_server keep-alive, undici and AbortSignal.timeout @@ -1961,7 +1961,7 @@ function installGlobalWindowAlias(g, w, tombstoneMs?) { get: capGetter, configurable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } function removeGlobalWindowAlias(g, w) { _aliasRefCount -= 1; @@ -1973,7 +1973,7 @@ function removeGlobalWindowAlias(g, w) { const restored = new Set(); if (_savedHostGlobalDescriptors) { for (const [name, desc] of Object.entries(_savedHostGlobalDescriptors)) { - try { Object.defineProperty(g, name, desc); } catch (_) {} + try { Object.defineProperty(g, name, desc); } catch (_) { /* best-effort: continue */ } restored.add(name); } _savedHostGlobalDescriptors = undefined; @@ -1990,15 +1990,15 @@ function removeGlobalWindowAlias(g, w) { if (restored.has(name)) continue; try { if (Object.getOwnPropertyDescriptor(g, name)?.get) names.push(name); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } for (const prop of ["window", "self", "top", "parent", "__capWindowFor"]) { if (restored.has(prop)) continue; try { if (Object.getOwnPropertyDescriptor(g, prop)?.get) names.push(prop); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { const t = _hostSetTimeout(() => { if (generation !== _aliasGeneration || _aliasRefCount > 0) return; @@ -2017,13 +2017,13 @@ function removeGlobalWindowAlias(g, w) { configurable: true, writable: true, }); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } }, _tombstoneMs); try { if (t && typeof t.unref === "function") t.unref(); - } catch (_) {} - } catch (_) {} + } catch (_) { /* best-effort: continue */ } + } catch (_) { /* best-effort: continue */ } } function destroyDom(win) { @@ -2031,19 +2031,19 @@ function destroyDom(win) { const cap = win.document.getElementById("cap"); if (cap) cap.replaceChildren(); win.happyDOM.close(); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { global.__cookieContainer = null; global.__browserFrame = null; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } try { if (typeof Bun !== "undefined") removeGlobalWindowAlias(globalThis, win); - } catch (_) {} + } catch (_) { /* best-effort: continue */ } // The scope holds window-bound timer functions; dropping it releases the // closed window and makes any straggler's `new Function` fall back to the // host constructor (harmless: the window registry is already cleared). removeGuestScope(win); - try { shutdownSyncFetchWorker(); } catch (_) {} + try { shutdownSyncFetchWorker(); } catch (_) { /* best-effort: continue */ } } function extractVerifyParam(param) { @@ -2120,7 +2120,7 @@ function stageReusableWindow(window, browserFrame) { function discardReusableWindow() { const p = _reusePool; if (p.window) { - try { destroyDom(p.window); } catch (_) {} + try { destroyDom(p.window); } catch (_) { /* best-effort: continue */ } } p.window = null; p.browserFrame = null; @@ -2248,7 +2248,7 @@ async function solveTraceless(opts) { const okPe = w.__lastPeUrl; if (okPe) _stallCounts.delete(okPe); w.__capErrs = []; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } solveSucceeded = true; const out = extractVerifyParam(param); @@ -2265,7 +2265,7 @@ async function solveTraceless(opts) { if (summary) { try { err.message = `${err && err.message ? err.message : String(err)} |${summary}`; - } catch (_) {} + } catch (_) { /* best-effort: continue */ } } throw err; } finally { diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 463aa4b7c0..e8d5127193 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -398,12 +398,12 @@ function zcodePlanDeviceMid(): string { try { const stored = readFileSync(file, "utf8").trim(); if (stored) return stored; - } catch {} + } catch { /* first run or unreadable: generate below */ } const mid = randomUUID(); try { mkdirSync(dir, { recursive: true }); writeFileSync(file, mid, { mode: 0o600 }); - } catch {} + } catch { /* persistence is best-effort; an unpersisted id still works per-process */ } return mid; } From 57f9cbe244ba2e62ebb82f215224d1afa4631bb6 Mon Sep 17 00:00:00 2001 From: Eros Date: Sat, 12 Sep 2026 22:19:20 +0100 Subject: [PATCH 4/4] fix(zcode-start-plan): address review findings Review response across the Codex review threads, CodeRabbit findings, and the triage review: - Confine the captcha solver to a dedicated worker thread (captcha-host.ts): the guest SDK's window aliasing, process-wide uncaughtException handler, Atomics.wait stalls, and any global mutation now live (and can only fail) inside the worker. Host-side deadline + crash handling fail only the pending solve. Verified live end to end. - Remove the process-global undici dispatcher entirely (undeclared dep + process-wide rerouting); proxy support is documented as per-call-site follow-up instead. - Serialize solves with a module-level chain so each challenged request gets a fresh single-use param (adapters are constructed per request). - Match ZCode attribution against the RESOLVED send URL in openai-chat (chatCompletionsPath) and openai-responses (responsesPath), with transport regression tests for metered chat/responses, forward-mode, pay-as-you-go, and configured-header precedence. - Strip the Claude Code identity system block the oauth-mode inner adapter injects; coerce (never drop) unrecognized caller system entries; pin the caller-system precedence with tests. - Bound the captcha config fetch (10s timeout + request abort signal) and thread ctx.abortSignal into the solve. - Login flow: fix the default polling interval fallthrough (1s -> 3s), bound init/poll fetches by the login deadline + caller signal, and harden sleep() against listener leaks and pre-aborted signals. - Quota: persist the device id under the configured OpenCodex home (getConfigDir) and normalize expires_at through normalizeResetAt. - /api/account-labels: Cache-Control: no-store. - Solver hardening: bound the sync-fetch SAB writes (capacity check + bounded fail()), 0o700 cache dir with 0o600 temp + atomic rename. - Hygiene: restore package version, drop committed dev scripts and local .mimosa session artifacts (now ignored), register tests in both layout manifests, document the provider in the guide and adapters reference. --- .../src/content/docs/guides/providers.md | 2 + .../src/content/docs/reference/adapters.md | 34 + ..._866f3f9a-3097-4f86-af44-a594358fdc75.json | 1 - ...83ee9d422c3729e816a64d57f4c246ed617.source | 1233 ----------------- ...097-4f86-af44-a594358fdc75-d20eead49b.json | 14 - scripts/dev-gateway-validate.ts | 41 - scripts/dev-mint-test.ts | 8 - scripts/test-layout/layout.json | 6 +- src/adapters/openai-chat.ts | 23 +- src/adapters/openai-responses.ts | 11 +- src/adapters/zcode-identity.ts | 17 +- src/adapters/zcode-start-plan.ts | 39 +- .../zcode-start-plan/body-transform.ts | 8 + src/adapters/zcode-start-plan/captcha-host.ts | 117 ++ .../zcode-start-plan/captcha-solver.ts | 32 +- src/oauth/zcode-start-plan.ts | 45 +- src/providers/quota.ts | 12 +- src/server/management/oauth-account-routes.ts | 6 +- tests/adapters/zcode-attribution.test.ts | 91 ++ tests/fixtures/test-layout-expected.json | 16 +- tests/providers/zcode-start-plan.test.ts | 45 +- 21 files changed, 417 insertions(+), 1384 deletions(-) delete mode 100644 gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json delete mode 100644 gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source delete mode 100644 gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json delete mode 100644 scripts/dev-gateway-validate.ts delete mode 100644 scripts/dev-mint-test.ts create mode 100644 src/adapters/zcode-start-plan/captcha-host.ts create mode 100644 tests/adapters/zcode-attribution.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c50ef934d2..4fec6f839c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -178,6 +178,7 @@ ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/au ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE ocx login devin # Cognition/Devin Auth0 browser sign-in ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) +ocx login zcode-start-plan # Z.ai Start Plan via the ZCode gateway (browser authorize → poll) ocx login codex # Codex account pool (aliases: chatgpt, openai; needs a running proxy) ocx logout ``` @@ -195,6 +196,7 @@ ocx logout | `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login opens Auth0 browser sign-in, then exchanges the token via Cognition's `RegisterUser` for a long-lived API key; models are discovered per account with `GetCascadeModelConfigs`. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. | | `devin-cli` | `devin` | `https://server.codeium.com` | Imports the credential your installed Devin CLI already holds (`devin auth login` writes it to its own `credentials.toml`), then streams over Cognition's Connect-RPC api-server like the `devin` provider — no browser sign-in and no key to paste. Model discovery and context windows come from your account's own catalog. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +| `zcode-start-plan` | `zcode-start-plan` | `https://zcode.z.ai/api/v1/zcode-plan/anthropic` | Serves the Z.ai Start Plan quota from the ZCode plan gateway — no ZCode desktop app needed. Login is the gateway's own OAuth CLI flow (`ocx login zcode-start-plan` → browser → poll); the stored plan JWT has no expiry and is re-obtained by re-login when the gateway rejects it. The gateway requires the official ZCode identity system blocks, client identity headers, and solves Aliyun WAF captcha challenges with an in-process traceless solver confined to a worker thread. Quota reads `billing/balance`; the `GLM-5.3-Flash` row accepts images. Per-window rate limits on a fresh identity surface as ordinary 429s. | Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8f524e39c1..565f3c4c44 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -504,3 +504,37 @@ or a permission grant. Unmarked clients retain their existing behavior. This repair runs before the separate provider `responsesSnapshotRepair` option and does not enable that broader lifecycle repair. Existing tool-search, custom-tool, function-completion and undeclared-tool handling keep their established order. + +## `zcode-start-plan` + +**Targets:** the ZCode plan gateway's Anthropic-wire messages endpoint at +`zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages`. +**Auth:** the plan JWT from `ocx login zcode-start-plan` (the gateway's OAuth CLI flow), +sent as `Authorization: Bearer`. The JWT carries no `exp` claim; when the gateway rejects +it, the account is marked for re-login — there is no silent refresh. + +- Requests mirror the official desktop client's LLM calls: the identity header set + (`User-Agent: ZCode/ ai-sdk/anthropic/3.0.81`, `X-Title`, `X-ZCode-Agent: glm` + last, no device id) plus fresh per-request attribution ids and + `x-zcode-session-type: main`. +- The gateway inspects the request body: the official ZCode system blocks are prepended to + `system` (the dynamic powered-by line merged into the trailing Environment block), the + client's two-phase `cache_control` marking is applied, and `metadata.user_id` is decoded + from the JWT. Without these the gateway answers biz code 3012. The Claude Code identity + block that the inner oauth-mode Anthropic adapter would add is stripped so the model sees + one identity. +- Aliyun WAF captcha challenges (biz 3007 in the body, or the verify-param response + header) mint a fresh verify param and replay the request once. The traceless solver runs + the official Aliyun Captcha 2.0 SDK inside a happy-dom window that lives entirely in a + dedicated worker thread — its window aliasing, exception handlers, and any stall can + never touch the server thread. A crash or hang fails only the pending solve. +- Business errors that arrive inside HTTP 200 bodies (for example `1005`, a per-window + rate limit) are mapped to real statuses (429/502) instead of surfacing as truncated + streams. A 3012 WAF block surfaces as `upstream_error`. +- Quota: per-account probe of `billing/balance` (requires the `X-Device-Mid` header, + persisted per install under the OpenCodex home; `ZCODE_DEVICE_MID` overrides). Balance + rows become custom quota windows. +- The static model list comes from the gateway's client config — the Anthropic route has + no `/models` listing, so live discovery stays off. + +See the [provider guide](/guides/providers/) for login instructions and operational notes. diff --git a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json deleted file mode 100644 index ef9a1211e5..0000000000 --- a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.json +++ /dev/null @@ -1 +0,0 @@ -{"touched":["/home/eros/.zcode/workspace/default/opencodex/gui/src/pages/Logs.tsx"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtyq6sgb-258204-db5b687414","createdAt":"2026-09-12T18:35:34.667Z","files":{"src/pages/Logs.tsx":{"existed":true,"snapshot":"9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../src/server/management/oauth-account-routes.ts","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../src/server/management/oauth-account-routes.ts","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-12T18:35:37.507Z"} \ No newline at end of file diff --git a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source b/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source deleted file mode 100644 index f498537d37..0000000000 --- a/gui/.mimosa/hook-state/sess_866f3f9a-3097-4f86-af44-a594358fdc75.mtyq6sgb-258204-db5b687414.baseline/9b5b7e3a77666cd2ac71d79eff9cb83ee9d422c3729e816a64d57f4c246ed617.source +++ /dev/null @@ -1,1233 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useI18n, LOCALES, type TFn } from "../i18n/shared"; -import { formatProviderDisplayName } from "../provider-icons"; -import { formatTokens } from "../format-tokens"; -import { hashLogConversationQuery } from "../log-conversation-id"; -import { statusCodeInfo } from "../status-codes"; -import { IconX } from "../icons"; -import { modelLabel } from "../model-display"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; -import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton } from "../components/data-surface"; -import { EmptyState, Notice } from "../ui"; -import Debug from "./Debug"; -import { LogsFilterBar } from "./logs-filter-bar"; -import { logsClockAnchor, logsClockNow, type LogsClockAnchor } from "./logs-clock"; -import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiveLogFilters, type LogFilterState } from "./logs-filter"; - -import type { LogsTab } from "./logs-tab-keydown"; -import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; -import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; -import { speedLabel } from "./logs-speed-label"; -import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; -import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; -import type { LogSurface } from "./logs-surface-filter"; -import { - sanitizeLogEntryRouteDecision, - validCachedRouteDecision, -} from "./log-route-decision"; -import { mergeLogDelta, parseLogPollResponse } from "./log-poll"; - -function logsCacheKey(apiBase: string): string { - return `ocx.logs.list.v1:${apiBase}`; -} - -const EMPTY_LOGS: LogEntry[] = []; - -interface UsageBreakdown { - inputTokens: number; - outputTokens: number; - /** Absolute active-context snapshot after the response (stateful providers such as Kiro). */ - contextTotalTokens?: number; - totalTokens?: number; - cachedInputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - reasoningOutputTokens?: number; - estimated?: boolean; -} - -type LogUsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; - -type MetricUnavailableReason = - | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration" - | "price_unmatched" | "invalid_cache_breakdown" - | "invalid_usage" | "combo_attempt_unavailable" - | "ttft_missing" | "decode_window_too_short"; - -type CostEstimateReason = - | "usage_estimated" - | "cache_detail_missing" - | "expected_price_overlay" - | "provider_cost_overlay" - | "priority_lower_bound"; - -type TokPerSecondResult = - | { kind: "value"; value: number; estimated: boolean } - | { kind: "unavailable"; reason: MetricUnavailableReason }; - -interface MatchedPriceInfo { - provider: string; - modelId: string; - jawcodeProvider?: string; - source: "jawcode" | "expected" | "user"; - sourceRef?: string; - verifiedAt?: string; - status: "verified" | "verified-derived"; -} - -type CostResult = - | { - kind: "value"; - estimate: { - cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; - estimated: boolean; - priorityLowerBound?: boolean; - price?: MatchedPriceInfo; - attempts?: Array<{ ordinal: number; price: MatchedPriceInfo }>; - }; - estimateReasons: CostEstimateReason[]; - } - | { kind: "unavailable"; reason: MetricUnavailableReason }; - -interface LogDisplayMetrics { - tokPerSecond: TokPerSecondResult; - /** - * Estimated decode throughput (#4038). Optional because a row cached by an older build has no - * such field; absent renders nothing rather than an empty slot. - */ - decodeTokPerSecond?: TokPerSecondResult; - cost: CostResult; -} - -/** - * Recovery kinds recorded on a log attempt; rendered as localized labels in the logs - * detail dialog instead of raw wire values. - */ -type AttemptRecoveryKind = - | "transient-5xx" - | "connection-reset" - | "oauth-401" - | "key-429" - | "rate-limit-429" - | "anthropic-oauth-429" - | "image-413" - | "empty-completion"; - -interface LogAttempt { - ordinal: number; - provider: string; - model: string; - adapter: string; - status: number; - durationMs: number; - sendCount: number; - recoveryKinds: AttemptRecoveryKind[]; - usageStatus: LogUsageStatus; - inputTokenEstimate?: number; - usage?: UsageBreakdown; - totalTokens?: number; - errorCode?: string; - firstOutputMs?: number; - requestedEffort?: string; - effectiveEffort?: string; - reasoningWireField?: string; - reasoningWireValue?: string | number | boolean; - displayMetrics?: LogDisplayMetrics; -} - -export interface LogEntry { - requestId?: string; - timestamp: number; - model: string; - provider: string; - /** Pool/account label the turn was served under (e.g. "p83fa8d", "main"); absent when unattributed. */ - accountLogLabel?: string; - surface?: LogSurface; - conversationId?: string; - /** - * The original helper model, when Shadow Call Intercept rewrote this request. - * - * Present ONLY for an intercepted request. A helper request that was not intercepted -- - * interception off, no replacement model, or a slug the matcher does not recognize -- is - * indistinguishable here from ordinary traffic, which is why the filter below says - * "intercepted" rather than "helper". - */ - shadowCallRewrittenFrom?: string; - requestedEffort?: string; - effectiveEffort?: string; - reasoningWireField?: string; - reasoningWireValue?: string | number | boolean; - requestedServiceTier?: string; - requestedSpeedLabel?: string; - configuredServiceTier?: string; - configuredSpeedLabel?: string; - responseServiceTier?: string; - // #2455: qualifies responseServiceTier in the model tooltip — the echoed tier alone - // cannot say whether Fast was granted on a backend whose echo is not authoritative. - tierOutcome?: ModelTitleTierOutcome; - resolvedModel?: string; - modelSupportsServiceTier?: boolean; - status: number; - durationMs: number; - errorCode?: string; - upstreamError?: string; - usageStatus?: LogUsageStatus; - usage?: UsageBreakdown; - totalTokens?: number; - firstOutputMs?: number; - attempts?: LogAttempt[]; - displayMetrics?: LogDisplayMetrics; - /** Bounded route-decision trace (RI-01); absent for pre-trace rows. */ - routeDecision?: { - routeKind?: string; - profile?: { id?: string; revision?: string }; - selected?: { provider?: string; model?: string; reason?: string }; - candidates?: Array<{ provider?: string; model?: string; eligible?: boolean; exclusions?: Array<{ code?: string }> }>; - }; -} - -function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { - if (!Array.isArray(cached)) return null; - for (const entry of cached) { - if ( - !entry - || typeof entry !== "object" - || typeof entry.timestamp !== "number" - || typeof entry.model !== "string" - || typeof entry.provider !== "string" - || typeof entry.status !== "number" - || typeof entry.durationMs !== "number" - || (entry.shadowCallRewrittenFrom !== undefined && typeof entry.shadowCallRewrittenFrom !== "string") - || !validCachedRouteDecision(entry.routeDecision) - ) { - return null; - } - } - return cached; -} - -function displayTokenTotal(log: LogEntry): number | undefined { - if (!log.usage) return typeof log.totalTokens === "number" ? log.totalTokens : undefined; - // inputTokens is inclusive of cache read/write (canonical convention, devlog 070); - // never re-add cache detail. max() keeps legacy pre-070 rows honest. - const baseTotal = log.usage.inputTokens + log.usage.outputTokens; - const explicitTotal = log.usage.totalTokens ?? log.totalTokens; - return typeof explicitTotal === "number" ? Math.max(explicitTotal, baseTotal) : baseTotal; -} - -/** - * Row/detail display total that also honors an absolute context checkpoint. - * - * Stateful providers (Kiro) report per-attempt usage only, so their per-request total stays - * small while the real active context grows. `contextTotalTokens` is that absolute snapshot. - * - * NEVER SUM THIS ACROSS REQUESTS. A checkpoint is not a per-request delta: adding it up over - * a conversation counts the same context once per request and inflates aggregates wildly. - * Aggregate rollups must keep using `displayTokenTotal`. - */ -function displayContextTokenTotal(log: LogEntry): number | undefined { - const base = displayTokenTotal(log); - const contextTotal = log.usage?.contextTotalTokens; - if (typeof contextTotal !== "number") return base; - return Math.max(base ?? 0, contextTotal) || undefined; -} - -interface ReasoningLogFields { - requestedEffort?: string; - effectiveEffort?: string; - reasoningWireField?: string; - reasoningWireValue?: string | number | boolean; -} - -function effortLabel(log: ReasoningLogFields): string { - const requested = log.requestedEffort?.replace(/\s*->\s*/g, " → "); - const effective = log.effectiveEffort; - if (!requested) return effective ?? "-"; - // requestedEffort may already contain a cap/clamp chain (for example max->high). - // Only append the adapter result when it differs from that chain's terminal value. - if (!effective || requested === effective || requested.split(" → ").at(-1) === effective) return requested; - return `${requested} → ${effective}`; -} - -function reasoningWireLabel(log: ReasoningLogFields): string | undefined { - if (!log.reasoningWireField || log.reasoningWireValue === undefined) return undefined; - return `${log.reasoningWireField}=${log.reasoningWireValue}`; -} - -function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: string): string { - if (!result || result.kind === "unavailable" || !Number.isFinite(result.value) || result.value <= 0) return "\u2014"; - const digits = result.value >= 100 ? 0 : 1; - const value = new Intl.NumberFormat(localeTag, { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }).format(result.value); - return `${result.estimated ? "~" : ""}${value}`; -} - -const LOGS_POLL_INTERVAL_MS = 2000; -// Relative time filters must advance even when the polled snapshot is unchanged. Keep the -// refresh independent from the network poll so an active 15m/1h/24h window expires rows while -// the proxy is idle. -const LOGS_FILTER_CLOCK_INTERVAL_MS = 30_000; -const LOGS_POLL_BACKOFF_MAX_EXPONENT = 4; -/** Consecutive failed polls before a stale table is called out. */ -const STALE_POLL_FAILURE_LIMIT = 3; - -const METRIC_REASON_KEYS = { - usage_missing: "logs.detail.reason.usage_missing", - usage_unsupported: "logs.detail.reason.usage_unsupported", - output_missing: "logs.detail.reason.output_missing", - invalid_duration: "logs.detail.reason.invalid_duration", - price_unmatched: "logs.detail.reason.price_unmatched", - invalid_cache_breakdown: "logs.detail.reason.invalid_cache_breakdown", - invalid_usage: "logs.detail.reason.invalid_usage", - combo_attempt_unavailable: "logs.detail.reason.combo_attempt_unavailable", - ttft_missing: "logs.detail.reason.ttft_missing", - decode_window_too_short: "logs.detail.reason.decode_window_too_short", -} as const satisfies Record; - -const ESTIMATE_REASON_KEYS = { - usage_estimated: "logs.detail.estimate.usage_estimated", - cache_detail_missing: "logs.detail.estimate.cache_detail_missing", - expected_price_overlay: "logs.detail.estimate.expected_price_overlay", - provider_cost_overlay: "logs.detail.estimate.provider_cost_overlay", - priority_lower_bound: "logs.detail.estimate.priority_lower_bound", -} as const satisfies Record; - -/** - * i18n keys for every {@link AttemptRecoveryKind}, so the logs detail dialog renders a - * localized label instead of the raw wire value (e.g. `rate-limit-429`). - */ -const RECOVERY_KIND_KEYS = { - "transient-5xx": "logs.detail.attempt.recovery.transient5xx", - "connection-reset": "logs.detail.attempt.recovery.connectionReset", - "oauth-401": "logs.detail.attempt.recovery.oauth401", - "key-429": "logs.detail.attempt.recovery.key429", - "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", - "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", - "image-413": "logs.detail.attempt.recovery.image413", - "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", -} as const satisfies Record; - -/** Map a metric-unavailable reason to its i18n key. */ -function metricReasonKey(reason: MetricUnavailableReason) { - return METRIC_REASON_KEYS[reason]; -} - -/** Map a cost-estimate reason to its i18n key. */ -function estimateReasonKey(reason: CostEstimateReason) { - return ESTIMATE_REASON_KEYS[reason]; -} - -/** - * Map one attempt recovery kind to its i18n key for the logs detail dialog. - */ -function recoveryKindKey(kind: AttemptRecoveryKind) { - // A stale/malformed cached row can carry a kind outside the known set; fall back to a - // localized label instead of handing `t()` an undefined key. - return RECOVERY_KIND_KEYS[kind] ?? "logs.detail.attempt.recovery.unknown"; -} - -function verificationKey(status: MatchedPriceInfo["status"]): "logs.detail.verification.verified" | "logs.detail.verification.derived" { - return status === "verified" ? "logs.detail.verification.verified" : "logs.detail.verification.derived"; -} - -function statusColor(status: number): string { - if (status >= 200 && status < 300) return "var(--green)"; - if (status >= 400) return "var(--red)"; - return "var(--amber)"; -} - -/** Date and time as separate locale strings (no joining comma) for stacked table cells. */ -function formatLogDateParts(ts: number, localeTag?: string, timeZone?: string): { date: string; time: string } { - const zone = timeZone ? { timeZone } : undefined; - try { - return { - date: new Date(ts).toLocaleDateString(localeTag, zone), - time: new Date(ts).toLocaleTimeString(localeTag, zone), - }; - } catch { - // An IANA zone the browser's ICU build does not know throws RangeError. - return { - date: new Date(ts).toLocaleDateString(localeTag), - time: new Date(ts).toLocaleTimeString(localeTag), - }; - } -} - -function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string { - const { date, time } = formatLogDateParts(ts, localeTag, timeZone); - return `${date} ${time}`; -} - -function summarizeFilteredLogs(entries: LogEntry[]): { - requests: number; - totalTokens: number; - estimatedCostUsd: number; - priorityLowerBound: boolean; - unpricedRequests: number; - unmeteredRequests: number; -} { - let totalTokens = 0; - for (const entry of entries) { - const tokens = displayTokenTotal(entry); - if (tokens !== undefined) totalTokens += tokens; - } - return { - requests: entries.length, - totalTokens, - ...summarizeEstimatedCosts(entries), - }; -} - -export default function Logs({ apiBase }: { apiBase: string }) { - const { t, locale } = useI18n(); - const resourceKey = logsCacheKey(apiBase); - const cachedLogs = validCachedLogs(readSessionListCache(resourceKey)); - const [autoRefresh, setAutoRefresh] = useState(true); - const [failureStreak, setFailureStreak] = useState<{ error: unknown; count: number }>( - { error: null, count: 0 }, - ); - const [detail, setDetail] = useState(null); - const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); - const [filterClockNow, setFilterClockNow] = useState(() => Date.now()); - const filterClockRef = useRef<{ - key: string; anchor?: LogsClockAnchor; active: boolean; request: number; - }>({ key: resourceKey, active: false, request: 0 }); - const logPollRef = useRef<{ key: string; cursor: string | null; rows: LogEntry[] }>( - { key: resourceKey, cursor: null, rows: [] }, - ); - // Invalidate the old resource at commit, before passive resource-loader effects. - // A late body read must not mutate this page's clock, cache or retry state. - useLayoutEffect(() => { - const clock = { key: resourceKey, active: true, request: 0 }; - filterClockRef.current = clock; - // Cached display rows never establish a cursor, including A -> B -> A. - logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; - setFilterClockNow(Date.now()); - return () => { clock.active = false; }; - }, [resourceKey]); - const readFilterClockNow = useCallback(() => { - const clock = filterClockRef.current; - return logsClockNow(clock.key === resourceKey ? clock.anchor : undefined, performance.now(), Date.now()); - }, [resourceKey]); - const scrollContainerRef = useRef(null); - const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( - { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, - ); - const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; - // The proxy's own zone, so timestamps read the same as the server's logs rather than being - // silently shifted into the viewer's zone (#725). Fetched once: it cannot change while the - // page is open, so it must not join the 2s log poll. Undefined until it arrives, which - // formats browser-local exactly as before. - const [serverTimeZone, setServerTimeZone] = useState(); - useEffect(() => { - const controller = new AbortController(); - // Abort already rejects the in-flight fetch, but the flag keeps the guarantee - // local: the setter is visibly gated without having to reason about whether - // the abort propagates through the body read. - let cancelled = false; - fetch(`${apiBase}/api/settings`, { signal: controller.signal }) - .then(res => (res.ok ? res.json() as Promise<{ timeZone?: unknown }> : null)) - .then(body => { - if (cancelled || !body) return; - if (typeof body.timeZone === "string" && body.timeZone.trim()) { - setServerTimeZone(body.timeZone.trim()); - } - }) - .catch(() => { - // Offline or an older proxy without the field: keep browser-local formatting. - }); - return () => { - cancelled = true; - controller.abort(); - }; - }, [apiBase]); - // Opaque log labels → human attribution (email masked per proxy privacy settings). - // Fetched once per page: labels are stable for the lifetime of an account. - const [accountLabels, setAccountLabels] = useState>(new Map()); - useEffect(() => { - const controller = new AbortController(); - let cancelled = false; - fetch(`${apiBase}/api/account-labels`, { signal: controller.signal }) - .then(res => (res.ok ? res.json() as Promise<{ labels?: Array<{ label?: unknown; email?: unknown; plan?: unknown }> }> : null)) - .then(body => { - if (!body?.labels) return; - const map = new Map(); - for (const row of body.labels) { - if (typeof row.label !== "string" || !row.label) continue; - const parts: string[] = []; - if (typeof row.email === "string" && row.email) parts.push(row.email); - if (typeof row.plan === "string" && row.plan) parts.push(row.plan); - if (parts.length > 0) map.set(row.label, parts.join(" · ")); - } - setAccountLabels(map); - }) - .catch(() => { - // Older proxy without the endpoint: fall back to the raw opaque labels. - }); - return () => { - cancelled = true; - controller.abort(); - }; - }, [apiBase]); - // The hash is the source of truth for the active tab (#logs vs #logs/debug), - // so refresh/bookmark/back-forward keep the tab choice. - const [tab, setTab] = useState(readTabFromHash); - // Lazy-mount Debug on first visit, then keep it mounted so switch toggles - // and Logs↔Debug hops do not remount (avoids settings/log refetch storms). - const [debugMounted, setDebugMounted] = useState(() => readTabFromHash() === "debug"); - - useEffect(() => { - const onHash = () => setTab(readTabFromHash()); - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); - }, []); - - useEffect(() => { - if (tab === "debug") setDebugMounted(true); - }, [tab]); - - const selectTab = selectLogsTab; - - const loadLogs = useCallback(async (signal: AbortSignal): Promise => { - const clock = filterClockRef.current; - if (signal.aborted || !clock.active || clock.key !== resourceKey) { - throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); - } - const request = ++clock.request; - const isCurrent = () => !signal.aborted && clock.active - && filterClockRef.current === clock && clock.request === request; - let retry = logRetryRef.current; - if (retry.key !== resourceKey) { - retry = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; - logRetryRef.current = retry; - } - if (retry.failures > 0 && Date.now() < retry.nextAttemptAt) throw retry.error; - const poll = logPollRef.current; - const cursor = poll.key === resourceKey ? poll.cursor : null; - const url = `${apiBase}/api/logs?limit=2000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; - try { - const res = await fetch(url, { signal }); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - const body: unknown = await res.json(); - const receivedAt = performance.now(); - const parsed = parseLogPollResponse(body); - const incoming = parsed.rows.map(sanitizeLogEntryRouteDecision); - const next = cursor && parsed.cursor && !parsed.reset - ? mergeLogDelta(poll.rows, incoming) : incoming; - // The resource-store generation guard runs only after this loader returns. - // Guard these local side effects here as fetch/body readers may ignore abort. - if (!isCurrent()) throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); - logPollRef.current = { key: resourceKey, cursor: parsed.cursor, rows: next }; - // Reconcile when the accepted snapshot changes, using the latest user state - // rather than filters captured when the request started. Persist disappearance - // as All so a later ring cannot resurrect a cleared selection. - const options = extractLogFilterOptions(next); - setFilters(previous => { - const model = previous.model.trim().toLowerCase(); - const provider = previous.provider.trim().toLowerCase(); - const nextModel = model - ? options.models.find(option => option.trim().toLowerCase() === model) ?? "" - : ""; - const nextProvider = provider - ? options.providers.find(option => option.trim().toLowerCase() === provider) ?? "" - : ""; - if (previous.model === nextModel && previous.provider === nextProvider) return previous; - return { ...previous, model: nextModel, provider: nextProvider }; - }); - const sample = logsClockAnchor(parsed.generatedAt, receivedAt); - if (sample) clock.anchor = sample; - setFilterClockNow(logsClockNow(clock.anchor, receivedAt, Date.now())); - logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; - writeSessionListCache(resourceKey, next); - return next; - } catch (error) { - if (!isCurrent()) throw error; - const normalized = error ?? new Error("log request failed"); - const failures = retry.failures + 1; - const backoffMs = LOGS_POLL_INTERVAL_MS * (2 ** Math.min( - failures, - LOGS_POLL_BACKOFF_MAX_EXPONENT, - )); - logRetryRef.current = { key: resourceKey, failures, nextAttemptAt: Date.now() + backoffMs, error: normalized }; - throw normalized; - } - }, [apiBase, resourceKey]); - - // The resource layer owns the request and the 2s base poll. loadLogs backs off actual network - // attempts after failures while preserving those shared scheduler ticks and held rows. - const logsResource = useDataSurface( - resourceKey, - [apiBase], - loadLogs, - { - isEmpty: rows => rows.length === 0, - enabled: tab === "logs", - pollMs: autoRefresh ? LOGS_POLL_INTERVAL_MS : undefined, - initialData: cachedLogs ?? undefined, - }, - ); - const logsState = logsResource.state; - const logs = logsState.data ?? cachedLogs ?? EMPTY_LOGS; - const fetchLogs = logsResource.refresh; - const retryLogs = useCallback(() => { - logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; - logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; - fetchLogs({ forceLoading: true }); - }, [fetchLogs, resourceKey]); - - // A single failed tick on a two-second poll is noise, but an outage that never recovers must not - // leave the user reading stale rows as if they were current. Count consecutive failures and speak - // up once it is clearly not transient. - const settledFailure = !logsResource.refreshing && logsState.showError; - const settledSuccess = !logsResource.refreshing && !logsState.showError && logsState.data !== undefined; - // Derived from the settlement itself, so there is no second copy of this state to keep - // in sync and no frame painted with a stale banner. `streak` counts CONSECUTIVE failed - // settlements: it is stored keyed by the error identity that produced it, so repeated - // renders of the same failure do not inflate the count and a success clears it. - if (settledSuccess && failureStreak.count !== 0) { - setFailureStreak({ error: null, count: 0 }); - } else if (settledFailure && failureStreak.error !== logsState.error) { - setFailureStreak(previous => ({ error: logsState.error, count: previous.count + 1 })); - } - // Auto-refresh off: one settled failure is enough — there is no next poll to recover quietly. - const pollFailing = - failureStreak.count >= STALE_POLL_FAILURE_LIMIT - || (!autoRefresh && settledFailure); - - const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; - const conversationQuery = filters.conversationId.trim(); - - useEffect(() => { - if (filters.timeWindow === "all" || tab !== "logs") return; - setFilterClockNow(readFilterClockNow()); - const timer = window.setInterval(() => setFilterClockNow(readFilterClockNow()), LOGS_FILTER_CLOCK_INTERVAL_MS); - return () => window.clearInterval(timer); - }, [filters.timeWindow, tab, readFilterClockNow]); - - useEffect(() => { - let cancelled = false; - if (!conversationQuery) { - setFilters(prev => prev.conversationQueryHash === undefined ? prev : { ...prev, conversationQueryHash: undefined }); - return; - } - void hashLogConversationQuery(conversationQuery).then(hash => { - if (!cancelled) setFilters(prev => prev.conversationQueryHash === hash ? prev : { ...prev, conversationQueryHash: hash }); - }); - return () => { cancelled = true; }; - }, [conversationQuery]); - - const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); - const activeFilters = hasActiveLogFilters(filters); - const filteredLogs = useMemo(() => filterLogs(logs, filters, filterClockNow), [logs, filters, filterClockNow]); - const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; - - // TanStack Virtual returns unstable function identities; React Compiler skips this call. - // eslint-disable-next-line react-hooks/incompatible-library -- known useVirtualizer limitation - const rowVirtualizer = useVirtualizer({ - count: filteredLogs.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: () => 92, - overscan: 15, - getItemKey: index => { - const log = filteredLogs[filteredLogs.length - 1 - index]!; - return log.requestId ?? `${log.timestamp}:${log.model}:${log.provider}`; - }, - }); - const virtualRows = rowVirtualizer.getVirtualItems(); - const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; - const paddingBottom = virtualRows.length > 0 - ? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end - : 0; - - return ( -
-
-

{t("nav.logs")}

- {tab === "logs" && ( - - )} -
-
- - -
- - {debugMounted && ( - - )} - - -
- ); -} - -function useModalDialog(open: boolean) { - const ref = useRef(null); - useEffect(() => { - const el = ref.current; - if (!el) return; - if (open && !el.open) el.showModal(); - else if (!open && el.open) el.close(); - }, [open]); - return ref; -} - -function LogDetailDialog({ - detail, detailInfo, localeCode, localeTag, serverTimeZone, t, onClose, onFilterConversation, -}: { - detail: LogEntry; - detailInfo: ReturnType | null; - localeCode: string; - localeTag?: string; - serverTimeZone?: string; - t: TFn; - onClose: () => void; - onFilterConversation?: (conversationId: string) => void; -}) { - const dialogRef = useModalDialog(true); - const [copied, setCopied] = useState(false); - const tokenSplit = cacheSplit(detail); - const cost = detail.displayMetrics?.cost; - const reasoningWire = reasoningWireLabel(detail); - - const copyRequestId = async () => { - if (!detail.requestId) return; - try { - await navigator.clipboard.writeText(detail.requestId); - setCopied(true); - window.setTimeout(() => setCopied(false), 1200); - } catch { - // copy failure must not break the dialog - } - }; - - return ( - { e.preventDefault(); onClose(); }} - > - - - {detailInfo &&

{detailInfo.description}

} - -
-

{t("logs.detail.section.basic")}

-
- {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} - {t("logs.col.request")} - - {detail.requestId ?? "\u2014"} - {detail.requestId && ( - - )} - - {detail.conversationId && ( - <> - {t("logs.detail.conversation")} - - {detail.conversationId} - {onFilterConversation && ( - - )} - - - )} - {t("logs.col.model")}{modelLabel(detail.resolvedModel ?? detail.model)} - {t("logs.col.provider")}{formatProviderDisplayName(detail.provider, t)} - {(detail.requestedEffort || detail.effectiveEffort) && ( - <>{t("logs.col.effort")}{effortLabel(detail)}{reasoningWire ? ` (${reasoningWire})` : ""} - )} - {detail.errorCode && (<>{t("logs.col.error")}{detail.errorCode})} - {detail.upstreamError && (<>{t("logs.col.upstreamReason")}{detail.upstreamError})} -
-
- -
-

{t("logs.detail.route.section")}

- {detail.routeDecision ? ( -
- {t("logs.detail.route.kind")}{detail.routeDecision.routeKind ?? "–"} - {detail.routeDecision.profile?.id && ( - <>{t("logs.detail.route.profile")} - {detail.routeDecision.profile.id} ({detail.routeDecision.profile.revision}) - )} - {detail.routeDecision.selected?.provider && ( - <>{t("logs.detail.route.selected")} - - {detail.routeDecision.selected.provider}/{detail.routeDecision.selected.model} - {detail.routeDecision.selected.reason ? ` — ${detail.routeDecision.selected.reason}` : ""} - - )} - {t("logs.detail.route.candidates")} - - {(detail.routeDecision.candidates ?? []).map(candidate => { - const provider = typeof candidate.provider === "string" && candidate.provider.length > 0 - ? candidate.provider - : "–"; - const model = typeof candidate.model === "string" && candidate.model.length > 0 - ? candidate.model - : "–"; - const mark = candidate.eligible === true - ? " ✓" - : candidate.eligible === false - ? " ✗" - : " ?"; - return `${provider}/${model}${mark}`; - }).join(" ") || "–"} - -
- ) : ( -

{t("logs.detail.route.unknown")}

- )} -
- -
-

{t("logs.detail.section.performance")}

-
- {t("logs.col.duration")}{detail.durationMs}ms - {t("logs.col.tokPerSec")}{formatTokPerSecond(detail.displayMetrics?.tokPerSecond, localeTag)} - {detail.displayMetrics?.decodeTokPerSecond?.kind === "value" && ( - <>{t("logs.detail.decodeTokPerSec")}{formatTokPerSecond(detail.displayMetrics.decodeTokPerSecond, localeTag)} - )} - {detail.firstOutputMs !== undefined && ( - <>{t("logs.detail.ttft")}{detail.firstOutputMs}ms - )} -
- {detail.displayMetrics?.tokPerSecond.kind === "unavailable" && ( -

{t(metricReasonKey(detail.displayMetrics.tokPerSecond.reason))}

- )} - {detail.displayMetrics?.decodeTokPerSecond?.kind === "unavailable" && ( -

- {t("logs.detail.decodeTokPerSec")}: {t(metricReasonKey(detail.displayMetrics.decodeTokPerSecond.reason))} -

- )} -
- -
-

{t("logs.detail.section.cost")}

-

{t("usage.cost.disclaimer")}

- {cost?.kind === "value" ? ( - <> -
- {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, t, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, t, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, t, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, t, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, t, localeTag, cost.estimate.priorityLowerBound)} - {cost.estimate.price && ( - <> - {t("logs.detail.matchedKey")} - {cost.estimate.price.jawcodeProvider ?? cost.estimate.price.provider}/{cost.estimate.price.modelId} - {t("logs.detail.priceSource")} - {t(`logs.detail.source.${cost.estimate.price.source}`)} · {t(verificationKey(cost.estimate.price.status))} - - )} -
- {cost.estimateReasons.length > 0 && ( -
    - {cost.estimateReasons.map(reason =>
  • {t(estimateReasonKey(reason))}
  • )} -
- )} - - ) : ( -
- {t("logs.detail.costTotal")}{t("logs.cost.unavailable")} - {t("logs.detail.unavailableReason")} - {cost?.kind === "unavailable" ? t(metricReasonKey(cost.reason)) : t("logs.detail.reason.usage_missing")} -
- )} -
- - {detail.attempts?.length ? ( -
-

{t("logs.detail.section.attempts")}

-

{t("logs.detail.attempt.e2eNote")}

-
- - - - - - - - - - {detail.attempts.toSorted((a, b) => a.ordinal - b.ordinal).map(attempt => { - const attemptCost = attempt.displayMetrics?.cost; - const attemptReasoningWire = reasoningWireLabel(attempt); - const matched = attemptCost?.kind === "value" ? attemptCost.estimate.price : undefined; - const reason = attempt.errorCode - ?? (attempt.recoveryKinds.length - ? attempt.recoveryKinds.map(kind => t(recoveryKindKey(kind))).join(", ") - : undefined) - ?? (attemptCost?.kind === "unavailable" ? t(metricReasonKey(attemptCost.reason)) : t("logs.detail.attempt.completed")); - return ( - - - - - - - - - ); - })} -
#{t("logs.detail.attempt.target")}{t("logs.col.duration")}{t("logs.col.tokPerSec")}{t("logs.col.estimatedCost")}{t("logs.detail.attempt.reason")}
{attempt.ordinal} - {formatProviderDisplayName(attempt.provider, t)}
- {attempt.model} - {(attempt.requestedEffort || attempt.effectiveEffort) && ( - <> -
- - {effortLabel(attempt)}{attemptReasoningWire ? ` (${attemptReasoningWire})` : ""} - - - )} - {matched && ( - <> -
- - {matched.jawcodeProvider ?? matched.provider}/{matched.modelId} · {t(`logs.detail.source.${matched.source}`)} · {t(verificationKey(matched.status))} - - - )} -
{attempt.durationMs}ms - {formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} - {/* #4038: the DTO already carries a per-attempt decode rate measured on - that attempt's own TTFT, so the attempt table stacks it the same way - the parent row and the list do. */} - {attempt.displayMetrics?.decodeTokPerSecond?.kind === "value" && ( - - {formatTokPerSecond(attempt.displayMetrics.decodeTokPerSecond, localeTag)} - - )} - {formatEstimatedUsd(attemptCost, t, localeTag)}{reason}
-
-
- ) : null} - -
-

{t("logs.detail.section.usage")}

-
- {t("logs.tokens.input")}{detail.usage ? formatTokens(detail.usage.inputTokens, localeCode) : "\u2014"} - {t("logs.tokens.output")}{detail.usage ? formatTokens(detail.usage.outputTokens, localeCode) : "\u2014"} - {t("logs.tokens.cacheRead")}{tokenSplit.read !== undefined ? formatTokens(tokenSplit.read, localeCode) : "\u2014"} - {t("logs.tokens.cacheWrite")}{tokenSplit.write !== undefined ? formatTokens(tokenSplit.write, localeCode) : "\u2014"} - {t("logs.tokens.reasoning")}{detail.usage?.reasoningOutputTokens !== undefined ? formatTokens(detail.usage.reasoningOutputTokens, localeCode) : "\u2014"} - {t("logs.detail.totalTokens")}{displayContextTokenTotal(detail) !== undefined ? formatTokens(displayContextTokenTotal(detail)!, localeCode) : "\u2014"} - {detail.usage?.contextTotalTokens !== undefined && ( - <> - {t("logs.tokens.contextTotal")} - {formatTokens(detail.usage.contextTotalTokens, localeCode)} - - )} -
- {detail.usageStatus === "estimated" && ( -

{t("logs.tokens.estimatedNote")}

- )} -
- -
- {t("logs.detailRaw")} -
{JSON.stringify(detail, null, 2)}
-
- -
- ); -} diff --git a/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json b/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json deleted file mode 100644 index ed5301b7d3..0000000000 --- a/gui/.mimosa/hook-status/sess_866f3f9a-3097-4f86-af44-a594358fdc75-d20eead49b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schemaVersion": "mimosa-hook-status/v1", - "recordedAt": "2026-09-12T18:35:37.508Z", - "sessionId": "sess_866f3f9a-3097-4f86-af44-a594358fdc75", - "event": "PostToolUse", - "toolName": "Edit", - "file": "src/pages/Logs.tsx", - "outcome": "clear", - "coverage": "complete", - "findingCount": 0, - "durationMs": 5, - "hostState": "hook_complete", - "reportHint": ".mimosa/reports/" -} diff --git a/scripts/dev-gateway-validate.ts b/scripts/dev-gateway-validate.ts deleted file mode 100644 index cc409d8807..0000000000 --- a/scripts/dev-gateway-validate.ts +++ /dev/null @@ -1,41 +0,0 @@ -// One-shot gateway validation through the REAL adapter code path (Bun fetch). -// Run: bun scripts/dev-gateway-validate.ts (dev-only, not shipped in the PR) -import { readFileSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; -import { buildLlmIdentityHeaders, buildTraceHeaders } from "../src/adapters/zcode-start-plan"; -import { transformStartPlanBody, userIdFromJwt } from "../src/adapters/zcode-start-plan/body-transform"; -import { solveTraceless } from "../src/adapters/zcode-start-plan/captcha-solver"; - -const store = JSON.parse(readFileSync("/home/eros/.opencodex/auth.json", "utf8")); -const jwt = store["zcode-start-plan"].accounts[0].credential.access as string; -const userId = userIdFromJwt(jwt); - -const param = await solveTraceless({ scene: "11xygtvd", region: "sgp", prefix: "no8xfe", timeoutMs: 30_000 }); -writeFileSync("/tmp/captcha-param.txt", param); -console.error("minted", param.length); - -const model = "GLM-5.3-Flash"; -const body = transformStartPlanBody( - JSON.stringify({ model, max_tokens: 16, stream: false, messages: [{ role: "user", content: "say OK" }] }), - model, - userId, -); - -const res = await fetch("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages", { - method: "POST", - redirect: "manual", - headers: { - ...buildLlmIdentityHeaders(), - ...buildTraceHeaders(), - authorization: `Bearer ${jwt}`, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - "X-Aliyun-Captcha-Verify-Param": param, - "X-Aliyun-Captcha-Verify-Region": "sgp", - }, - body, -}); -const text = await res.text(); -console.log("HTTP", res.status); -console.log(text.slice(0, 600)); -process.exit(res.ok ? 0 : 1); diff --git a/scripts/dev-mint-test.ts b/scripts/dev-mint-test.ts deleted file mode 100644 index e90e545fcc..0000000000 --- a/scripts/dev-mint-test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Standalone mint harness: exercises the solver without touching the gateway. -// Run: bun scripts/dev-mint-test.ts (dev-only, not shipped in the PR) -import { writeFileSync } from "node:fs"; -import { solveTraceless } from "../src/adapters/zcode-start-plan/captcha-solver"; - -const param = await solveTraceless({ scene: "11xygtvd", region: "sgp", prefix: "no8xfe", timeoutMs: 30000 }); -writeFileSync("/tmp/captcha-param.txt", param); -console.log("MINTED", param.length); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4f4ec44705..6ea9203f09 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1372,7 +1372,9 @@ "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", - "devin-cli-authmode-migration.test.ts": "providers" + "devin-cli-authmode-migration.test.ts": "providers", + "zcode-start-plan.test.ts": "providers", + "zcode-attribution.test.ts": "adapters" }, "migrated": [ "adapters", @@ -1398,4 +1400,4 @@ "web-search", "windows" ] -} +} \ No newline at end of file diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a9c5fe3d44..3042b66a75 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -91,10 +91,18 @@ function openAIChatTransport(provider: OcxProviderConfig): { if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); } - // Plan-metered GLM destinations (coding-plan paths on api.z.ai / open.bigmodel.cn) are - // attributed by client identity and ZCode-identified traffic receives the increased - // usage allowance; neutral headers would meter the same plan without the bonus. - const zcodeIdentity = isZcodePlanMeteredEndpoint(provider.baseUrl) + // A configured relative path wins, mirroring how the Responses adapter honours + // `responsesPath`. An upstream can serve both wires under different prefixes, and a + // per-model wire override only swaps the adapter, so without this the opted-in Chat + // request would be sent to the Responses base with `/chat/completions` appended. + const url = provider.chatCompletionsPath === undefined + ? openaiChatCompletionsUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`; + // Plan-metered GLM destinations are attributed by client identity and ZCode-identified + // traffic receives the increased usage allowance. The guard tests the RESOLVED send + // URL, not the configured base: the zai preset's baseUrl is the bare host while its + // chatCompletionsPath is the plan-metered coding route. + const zcodeIdentity = isZcodePlanMeteredEndpoint(url) ? { ...buildZcodeIdentityHeaders(), ...buildZcodeTraceHeaders("coding-plan") } : {}; const headers: Record = { @@ -104,13 +112,6 @@ function openAIChatTransport(provider: OcxProviderConfig): { }; if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); - // A configured relative path wins, mirroring how the Responses adapter honours - // `responsesPath`. An upstream can serve both wires under different prefixes, and a - // per-model wire override only swaps the adapter, so without this the opted-in Chat - // request would be sent to the Responses base with `/chat/completions` appended. - const url = provider.chatCompletionsPath === undefined - ? openaiChatCompletionsUrl(provider.baseUrl) - : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`; return { url, headers, hasCredential }; } diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d35003fa6f..c13923a9fb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2316,10 +2316,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { const translatorBudget = incoming.translatorBudget; - // Plan-metered GLM destinations (e.g. the BigModel Coding Plan Responses wire) are - // attributed by client identity; ZCode-identified traffic receives the increased - // usage allowance. Forward-mode (ChatGPT backend) is never plan-metered. - const zcodeIdentity = provider.authMode !== "forward" && isZcodePlanMeteredEndpoint(provider.baseUrl) + // Plan-metered GLM destinations are attributed by client identity; ZCode-identified + // traffic receives the increased usage allowance. The guard tests the RESOLVED send + // URL (responsesPath included); forward-mode (ChatGPT backend) is never plan-metered. + const resolvedResponsesUrl = provider.responsesPath === undefined + ? openaiResponsesUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`; + const zcodeIdentity = provider.authMode !== "forward" && isZcodePlanMeteredEndpoint(resolvedResponsesUrl) ? { ...buildZcodeIdentityHeaders(), ...buildZcodeTraceHeaders("coding-plan") } : {}; const headers: Record = { "Content-Type": "application/json", ...zcodeIdentity }; diff --git a/src/adapters/zcode-identity.ts b/src/adapters/zcode-identity.ts index b0b826ec5a..02dfab4d3b 100644 --- a/src/adapters/zcode-identity.ts +++ b/src/adapters/zcode-identity.ts @@ -54,14 +54,15 @@ function clientTimezone(): string { } } -/** True for plan-metered Z.ai/Zhipu destinations (coding-plan and plan-gateway routes). */ -export function isZcodePlanMeteredEndpoint(baseUrl: string | undefined): boolean { - if (!baseUrl) return false; - const url = baseUrl.replace(/\/+$/, "").toLowerCase(); - return url === "https://zcode.z.ai/api/v1/zcode-plan" - || url === "https://zcode.z.ai/api/v1/zcode-plan/anthropic" - || url === "https://api.z.ai/api/coding/paas/v4" - || url === "https://open.bigmodel.cn/api/coding/paas/v4" +/** True for plan-metered GLM destinations (coding-plan and plan-gateway send URLs). */ +export function isZcodePlanMeteredEndpoint(sendUrl: string | undefined): boolean { + if (!sendUrl) return false; + const url = sendUrl.replace(/\/+$/, "").toLowerCase(); + if (url.startsWith("https://zcode.z.ai/api/v1/zcode-plan")) return true; + return url === "https://api.z.ai/api/coding/paas/v4/chat/completions" + || url === "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions" + || url === "https://api.z.ai/api/v1/responses" + || url === "https://open.bigmodel.cn/api/v1/responses" || url === "https://open.bigmodel.cn/api/v1"; } diff --git a/src/adapters/zcode-start-plan.ts b/src/adapters/zcode-start-plan.ts index 940d2ef0bd..53784941f1 100644 --- a/src/adapters/zcode-start-plan.ts +++ b/src/adapters/zcode-start-plan.ts @@ -26,7 +26,7 @@ import { createAnthropicAdapter } from "./anthropic"; import type { AdapterFetchContext, AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { OcxParsedRequest, OcxProviderConfig } from "../types"; -import { solveTraceless } from "./zcode-start-plan/captcha-solver"; +import { solveTraceless } from "./zcode-start-plan/captcha-host"; import { transformStartPlanBody, userIdFromJwt } from "./zcode-start-plan/body-transform"; import { buildZcodeIdentityHeaders, buildZcodeTraceHeaders } from "./zcode-identity"; @@ -48,8 +48,12 @@ export function isZcodeStartPlanEndpoint(baseUrl: string | undefined): boolean { return !!baseUrl && /https:\/\/(zcode\.z\.ai|zcode\.chatglm\.site)\/api\/v1\/zcode-plan/.test(baseUrl); } -async function readCaptchaScene(): Promise<{ sceneId: string; prefix: string; region: string }> { - const res = await fetch(CAPTCHA_CONFIG_URL); +async function readCaptchaScene(signal?: AbortSignal): Promise<{ sceneId: string; prefix: string; region: string }> { + // Bounded: a stalled config endpoint must not outlive the challenged request itself. + const timeout = AbortSignal.timeout(10_000); + const res = await fetch(CAPTCHA_CONFIG_URL, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); if (!res.ok) throw new Error(`captcha config fetch failed: status ${res.status}`); const body = (await res.json()) as { data?: { configs?: { captcha?: { enabled?: boolean; sceneId?: string; prefix?: string; region?: string } } }; @@ -104,20 +108,25 @@ export function isCaptchaChallenge(status: number, headers: Headers, bodyText: s return !!bodyText && CHALLENGE_BODY_MARKERS.some(m => bodyText.includes(m)); } +/** + * Module-level solve chain: verify params are single-use, so solves run one at a time and + * every caller receives a FRESH param (a shared inflight promise would hand one consumed + * param to every concurrent challenger). Adapters are constructed per request, so this + * mutex has to live at module scope, not on the adapter instance. + */ +let solveChain: Promise = Promise.resolve(); + export function createZcodeStartPlanAdapter(provider: OcxProviderConfig): ProviderAdapter { const inner = createAnthropicAdapter(provider); - let inflightSolve: Promise<{ param: string; region: string }> | undefined; - const solveCaptcha = async (): Promise<{ param: string; region: string }> => { - // Serialized: verify params are single-use, so concurrent solves only burn risk score. - inflightSolve ??= (async () => { - const scene = await readCaptchaScene(); + const solveCaptcha = (signal?: AbortSignal): Promise<{ param: string; region: string }> => { + const mine = solveChain.then(async () => { + const scene = await readCaptchaScene(signal); const param = await solveTraceless({ scene: scene.sceneId, region: scene.region, prefix: scene.prefix, timeoutMs: 30_000 }); return { param, region: scene.region }; - })().finally(() => { - inflightSolve = undefined; }); - return inflightSolve; + solveChain = mine.catch(() => undefined); + return mine; }; const isChallenge = isCaptchaChallenge; @@ -146,9 +155,9 @@ export function createZcodeStartPlanAdapter(provider: OcxProviderConfig): Provid throw new Error("zcode-start-plan: no JWT — run ocx login zcode-start-plan"); } // The gateway inspects the body: without the ZCode identity system blocks it rejects - // with biz code 3012 even when auth and captcha pass. - const model = JSON.parse(built.body as string).model as string | undefined; - const body = transformStartPlanBody(built.body as string, model, userIdFromJwt(jwt.replace(/^Bearer /, ""))); + // with biz code 3012 even when auth and captcha pass. transformStartPlanBody parses + // the body tolerantly; the model id is already on the parsed request. + const body = transformStartPlanBody(built.body as string, parsed.modelId, userIdFromJwt(jwt.replace(/^Bearer /, ""))); return { ...built, body, @@ -180,7 +189,7 @@ export function createZcodeStartPlanAdapter(provider: OcxProviderConfig): Provid } catch { /* already drained */ } let captcha: { param: string; region: string }; try { - captcha = await solveCaptcha(); + captcha = await solveCaptcha(ctx?.abortSignal); } catch (err) { return new Response( JSON.stringify({ diff --git a/src/adapters/zcode-start-plan/body-transform.ts b/src/adapters/zcode-start-plan/body-transform.ts index 6ee9c20910..f6a25f9ebf 100644 --- a/src/adapters/zcode-start-plan/body-transform.ts +++ b/src/adapters/zcode-start-plan/body-transform.ts @@ -16,6 +16,7 @@ * decoded, preserving any other metadata fields. */ import systemBlocksJson from "./system-blocks.json"; +import { CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../../oauth/anthropic"; interface SystemBlock { type: "text"; @@ -51,11 +52,18 @@ function normalizeUserSystem(system: unknown): SystemBlock[] { if (typeof item === "string") { if (item.trim()) out.push({ type: "text", text: item }); } else if (isPlainObject(item) && item.type === "text" && typeof item.text === "string" && item.text.trim()) { + // The inner Anthropic adapter runs in oauth mode and prepends the Claude Code identity + // block; the plan gateway must see ONLY the ZCode identity, never a dual identity. + if (item.text === CLAUDE_CODE_SYSTEM_INSTRUCTION) continue; out.push({ type: "text", text: item.text, ...(isPlainObject(item.cache_control) ? { cache_control: item.cache_control as { type: "ephemeral" } } : {}), }); + } else if (isPlainObject(item)) { + // Never drop caller system content silently: surface it as text instead. + const text = typeof item.text === "string" ? item.text : JSON.stringify(item); + if (text.trim()) out.push({ type: "text", text }); } } return out; diff --git a/src/adapters/zcode-start-plan/captcha-host.ts b/src/adapters/zcode-start-plan/captcha-host.ts new file mode 100644 index 0000000000..a911035486 --- /dev/null +++ b/src/adapters/zcode-start-plan/captcha-host.ts @@ -0,0 +1,117 @@ +/** + * Worker-thread host for the captcha solver. + * + * The vendored solver (captcha-solver.ts) runs the remote Aliyun SDK inside a happy-dom + * window with JavaScript evaluation enabled, and under Bun those guest scripts execute + * against the host realm of whatever thread loads the module. Running it on the server + * thread would therefore expose the proxy process (its globals, its env, its credentials) + * to mutable CDN bytes, alias browser-like globals (document/window) process-wide, install + * a process-wide uncaughtException handler, and let the solver's synchronous-XHR + * Atomics.wait stall the server event loop. + * + * This host confines ALL of that to a dedicated worker thread: + * - the guest SDK, window aliases, and exception handlers live in the worker's realm; + * - the worker crashing or hanging terminates only the pending solve, never the server; + * - solves are serialized host-side, which also makes the solver's internal singletons + * (browser frame, cookie container, sync-fetch worker) safe by construction. + * + * The worker is spawned lazily on the first solve and kept for the process lifetime. + */ +import { Worker } from "node:worker_threads"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +interface SolveRequest { + id: number; + scene: string; + region: string; + prefix: string; + timeoutMs: number; +} + +let worker: Worker | null = null; +let nextId = 1; +const pending = new Map void; reject: (e: Error) => void }>(); + +function solverModuleUrl(): string { + return pathToFileURL(join(import.meta.dir, "captcha-solver.ts")).href; +} + +function ensureWorker(): Worker { + if (worker) return worker; + const entry = solverModuleUrl(); + const source = ` + const { parentPort } = require("node:worker_threads"); + let chain = Promise.resolve(); + parentPort.on("message", (m) => { + chain = chain.then(async () => { + try { + const mod = await import(${JSON.stringify(entry)}); + const param = await mod.solveTraceless({ scene: m.scene, region: m.region, prefix: m.prefix, timeoutMs: m.timeoutMs }); + parentPort.postMessage({ id: m.id, ok: true, param }); + } catch (err) { + parentPort.postMessage({ id: m.id, ok: false, error: String((err && err.message) || err) }); + } + }); + }); + `; + const w = new Worker(source, { eval: true }); + w.unref(); + w.on("message", (msg: { id: number; ok: boolean; param?: string; error?: string }) => { + const entry = pending.get(msg.id); + if (!entry) return; + pending.delete(msg.id); + if (msg.ok && typeof msg.param === "string") entry.resolve(msg.param); + else entry.reject(new Error(msg.error ?? "captcha worker solve failed")); + }); + w.on("error", (err: unknown) => { + // A crashed worker fails every pending solve and is discarded; the next solve respawns. + const message = err instanceof Error ? err.message : String(err); + for (const [, entry] of pending) entry.reject(new Error(`captcha worker crashed: ${message}`)); + pending.clear(); + worker = null; + }); + w.on("exit", (code) => { + if (worker === w) worker = null; + if (code !== 0) { + for (const [, entry] of pending) entry.reject(new Error(`captcha worker exited with code ${code}`)); + pending.clear(); + } + }); + worker = w; + return w; +} + +/** + * Mint one captcha verify param in the solver worker. Host-enforced deadline: a hung + * worker (guest stall beyond the solve timeout) fails the pending solve instead of + * blocking the caller forever. + */ +export function solveTraceless(opts: { + scene: string; + region: string; + prefix: string; + timeoutMs: number; +}): Promise { + const w = ensureWorker(); + const id = nextId++; + const request: SolveRequest = { id, ...opts }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error("captcha worker solve timed out")); + }, opts.timeoutMs + 15_000); + if (typeof timer === "object" && typeof timer.unref === "function") timer.unref(); + pending.set(id, { + resolve: (param) => { + clearTimeout(timer); + resolve(param); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, + }); + w.postMessage(request); + }); +} diff --git a/src/adapters/zcode-start-plan/captcha-solver.ts b/src/adapters/zcode-start-plan/captcha-solver.ts index 3f6ec77baa..0cc2ce44e1 100644 --- a/src/adapters/zcode-start-plan/captcha-solver.ts +++ b/src/adapters/zcode-start-plan/captcha-solver.ts @@ -13,7 +13,6 @@ */ import { GlobalWindow as Window, PropertySymbol } from "happy-dom"; import WindowBrowserContext from "happy-dom/lib/window/WindowBrowserContext.js"; -import { ProxyAgent, setGlobalDispatcher } from "undici"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -43,8 +42,11 @@ const SYNC_WORKER_SRC = ` const u8 = new Uint8Array(m.sab); // Fixed-size header (bytes), NOT i32.length * 4 — that is the whole SAB. const payloadAt = 64; + const capacity = u8.length - payloadAt; const fail = (msg) => { - const b = enc.encode(msg); + // Bounded: an overlong message must not itself throw past the notify, which + // would leave the host blocked in Atomics.wait until the full timeout. + const b = enc.encode(String(msg)).subarray(0, capacity); u8.set(b, payloadAt); i32[5] = b.length; i32[1] = 0; i32[2] = 0; i32[3] = 0; i32[4] = 0; i32[0] = 2; Atomics.notify(i32, 0); @@ -58,6 +60,8 @@ const SYNC_WORKER_SRC = ` const statusText = enc.encode(res.statusText || ""); const headersJson = enc.encode(JSON.stringify(headers)); const setCookieJson = enc.encode(JSON.stringify(setCookie)); + const needed = statusText.length + headersJson.length + setCookieJson.length + body.length; + if (needed > capacity) return fail("sync fetch response exceeds " + capacity + " bytes"); let off = payloadAt; u8.set(statusText, off); i32[2] = statusText.length; off += statusText.length; u8.set(headersJson, off); i32[3] = headersJson.length; off += headersJson.length; @@ -125,12 +129,10 @@ const _DEBUG = /^(1|true|yes)$/i.test( process.env.CAPTCHA_DEBUG || process.env.CAPTCHA_DEBUG_BODIES || "", ); -const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; -if (proxyUrl) { - try { - setGlobalDispatcher(new ProxyAgent(proxyUrl)); - } catch (_) { /* best-effort: continue */ } -} +// Proxy env support is deliberately NOT wired here: a process-global undici dispatcher +// installed by a solver would reroute unrelated traffic, and this module runs inside a +// dedicated worker thread anyway (see captcha-host.ts) so host fetches would not even +// see it. If proxy support is ever needed, pass a per-request dispatcher at the call site. // ── Globals shared across solves ──────────────────────────────────────────── const _requestLog = []; @@ -220,14 +222,12 @@ async function fetchAndStore(url) { _memCdnCache.set(url, buf); try { const p = diskPathFor(url); - fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); - fs.writeFileSync(p, buf); - // verify write completed (no partial file) - const stat = fs.statSync(p); - if (stat.size !== buf.length) { - process.stderr.write(`[cache-write-short] ${url} wrote ${stat.size}/${buf.length}b — rewrite\n`); - fs.writeFileSync(p, buf); - } + fs.mkdirSync(CDN_CACHE_DIR, { recursive: true, mode: 0o700 }); + // 0o600 temp + atomic rename: a reader never observes a partial cache file, and + // the final path never holds group/world-readable bytes even mid-write. + const tmp = `${p}.${crypto.randomUUID()}.tmp`; + fs.writeFileSync(tmp, buf, { mode: 0o600 }); + fs.renameSync(tmp, p); } catch (err) { if (_DEBUG) process.stderr.write(`[cache-write-err] ${url}: ${err.message}\n`); } diff --git a/src/oauth/zcode-start-plan.ts b/src/oauth/zcode-start-plan.ts index 3a14cdfc74..e8936f497c 100644 --- a/src/oauth/zcode-start-plan.ts +++ b/src/oauth/zcode-start-plan.ts @@ -47,21 +47,42 @@ function nonEmpty(value: unknown): string | undefined { const sleep = (ms: number, signal?: AbortSignal) => new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { - clearTimeout(timer); + if (signal?.aborted) { reject(signal.reason ?? new DOMException("aborted", "AbortError")); - }, { once: true }); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal!.reason ?? new DOMException("aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + +/** Bounded fetch helper: every login request carries the caller's signal and a deadline. */ +const boundedFetch = (url: string, init: RequestInit, deadline: number, signal?: AbortSignal): Promise => { + const remaining = Math.max(0, deadline - Date.now()); + return fetch(url, { + ...init, + signal: AbortSignal.any([ + AbortSignal.timeout(Math.min(20_000, remaining || 1)), + ...(signal ? [signal] : []), + ]), }); +}; /** Run one full browser login and return the stored credential. */ export async function loginZcodeStartPlan(ctrl: OAuthController): Promise { + const deadline = Date.now() + LOGIN_TIMEOUT_MS; const pollToken = randomBytes(32).toString("hex"); - const initRes = await fetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/init`, { + const initRes = await boundedFetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/init`, { method: "POST", headers: { authorization: `Bearer ${pollToken}`, "content-type": "application/json", "user-agent": SDK_UA }, body: JSON.stringify({ provider: "zai" }), - }); + }, deadline, ctrl.signal); const init = (await initRes.json().catch(() => undefined)) as CliInitResponse | undefined; const flowId = nonEmpty(init?.data?.flow_id); const authorizeUrl = nonEmpty(init?.data?.authorize_url); @@ -74,15 +95,19 @@ export async function loginZcodeStartPlan(ctrl: OAuthController): Promise 0 + ? Math.max(1000, intervalSec * 1000) + : DEFAULT_POLL_INTERVAL_MS; while (Date.now() < deadline) { await sleep(intervalMs, ctrl.signal); let poll: CliPollResponse | undefined; try { - const res = await fetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/poll/${encodeURIComponent(flowId)}`, { + const res = await boundedFetch(`${ZCODE_ORIGIN}/api/v1/oauth/cli/poll/${encodeURIComponent(flowId)}`, { headers: { authorization: `Bearer ${pollToken}`, "user-agent": SDK_UA }, - }); + }, deadline, ctrl.signal); poll = (await res.json().catch(() => undefined)) as CliPollResponse | undefined; } catch { continue; // transient poll errors retry until the flow deadline diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 7c37cacad4..5a5bf4fb4d 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { arch, homedir } from "node:os"; +import { arch } from "node:os"; import { join } from "node:path"; import { effectiveCodexAuthAccountId, @@ -16,6 +16,7 @@ import { resolveEnvValue } from "../config"; import { resolveProviderApiKey } from "./key-store"; import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; +import { getConfigDir } from "../config/paths"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; @@ -388,13 +389,14 @@ function isCanonicalZcodePlanBaseUrl(baseUrl: string): boolean { /** * Stable per-install device id the plan gateway's control plane expects on billing calls * (`X-Device-Mid`; its absence is answered with biz code 3001). Generated once and stored - * under the OpenCodex config dir; `ZCODE_DEVICE_MID` overrides (e.g. to reuse the desktop - * client's id so the gateway sees one continuous device). + * under the configured OpenCodex home (isolated OPENCODEX_HOME installs keep separate + * device identities); `ZCODE_DEVICE_MID` overrides (e.g. to reuse the desktop client's id + * so the gateway sees one continuous device). */ function zcodePlanDeviceMid(): string { const fromEnv = process.env.ZCODE_DEVICE_MID?.trim(); if (fromEnv) return fromEnv; - const dir = join(homedir(), ".config", "opencodex"); + const dir = getConfigDir(); const file = join(dir, "zcode-plan-device-mid"); try { const stored = readFileSync(file, "utf8").trim(); @@ -2523,7 +2525,7 @@ async function fetchZcodeStartPlanQuota(provider: string, accessToken: string): const ratio = used === undefined ? (total - (toFiniteNumber(entry.remaining_units ?? entry.remainingUnits) ?? total)) / total : used / total; const percent = normalizePercent(ratio * 100); if (percent === undefined) continue; - const expiresAt = toFiniteNumber(entry.expires_at ?? entry.expiresAt); + const expiresAt = normalizeResetAt(entry.expires_at ?? entry.expiresAt); windows.push({ label, percent, ...(expiresAt !== undefined ? { resetAt: expiresAt } : {}) }); } if (windows.length === 0) return AUTHORITATIVE_EMPTY_QUOTA; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index ed9daeb3bc..522b8da485 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -194,7 +194,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< ...(entry.plan ? { plan: entry.plan } : {}), }); } - return jsonResponse({ labels }); + // Identity data (provider, plan, email) must never be replayable from a browser cache + // after an account is removed or re-added. + const labelsResponse = jsonResponse({ labels }); + labelsResponse.headers.set("Cache-Control", "no-store"); + return labelsResponse; } // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker. diff --git a/tests/adapters/zcode-attribution.test.ts b/tests/adapters/zcode-attribution.test.ts new file mode 100644 index 0000000000..78655512ee --- /dev/null +++ b/tests/adapters/zcode-attribution.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +function parsed(modelId: string): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: false, + options: {}, + }; +} + +function chatHeaders(provider: OcxProviderConfig): Record { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + return adapter.buildRequest(parsed("glm-5.3"), { headers: new Headers() }).headers as Record; +} + +function responsesHeaders(provider: OcxProviderConfig): Record { + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)); + return adapter.buildRequest(parsed("glm-5.3"), { headers: new Headers() }).headers as Record; +} + +describe("ZCode attribution on plan-metered GLM transports", () => { + test("chat: coding-plan send path receives identity + coding-plan trace headers", () => { + // The real preset shape: bare-host baseUrl with the metered path in chatCompletionsPath. + const headers = chatHeaders({ + adapter: "openai-chat", + baseUrl: "https://api.z.ai", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + apiKey: "k", + }); + expect(headers["X-ZCode-Agent"]).toBe("glm"); + expect(headers["User-Agent"]).toMatch(/^ZCode\//); + expect(headers["x-query-id"]).toBeDefined(); + expect(headers["x-session-id"]).toBeDefined(); + expect(headers["x-zcode-session-type"]).toBe("main"); + }); + + test("chat: legacy full coding baseUrl also attributed", () => { + const headers = chatHeaders({ adapter: "openai-chat", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", apiKey: "k" }); + expect(headers["X-ZCode-Agent"]).toBe("glm"); + }); + + test("chat: pay-as-you-go and unrelated destinations are NOT attributed", () => { + const payg = chatHeaders({ adapter: "openai-chat", baseUrl: "https://open.bigmodel.cn/api/paas/v4", apiKey: "k" }); + expect(payg["X-ZCode-Agent"]).toBeUndefined(); + const other = chatHeaders({ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", apiKey: "k" }); + expect(other["X-ZCode-Agent"]).toBeUndefined(); + }); + + test("chat: configured provider headers keep winning over the injected identity", () => { + const headers = chatHeaders({ + adapter: "openai-chat", + baseUrl: "https://api.z.ai", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + apiKey: "k", + headers: { "X-ZCode-Agent": "custom", "X-Title": "My Title" }, + }); + expect(headers["X-ZCode-Agent"]).toBe("custom"); + expect(headers["X-Title"]).toBe("My Title"); + }); + + test("responses: plan-metered responses path (zai preset shape) is attributed", () => { + const headers = responsesHeaders({ + adapter: "openai-responses", + baseUrl: "https://api.z.ai", + responsesPath: "/api/v1/responses", + apiKey: "k", + }); + expect(headers["X-ZCode-Agent"]).toBe("glm"); + expect(headers["x-query-id"]).toBeDefined(); + }); + + test("responses: forward mode (ChatGPT backend) is never attributed", () => { + const headers = responsesHeaders({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }); + expect(headers["X-ZCode-Agent"]).toBeUndefined(); + expect(headers["x-query-id"]).toBeUndefined(); + }); + + test("responses: default (non-metered) path is not attributed", () => { + const headers = responsesHeaders({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", apiKey: "k" }); + expect(headers["X-ZCode-Agent"]).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0118a80600..27e042b394 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -375,6 +375,7 @@ "context-compat.test.ts": "codex-integration", "context-history-ownership.test.ts": "server", "context-history.test.ts": "server", + "context-window-seed-repair.test.ts": "providers", "continuation-dedup.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", @@ -464,6 +465,8 @@ "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", + "devin-cli-authmode-migration.test.ts": "providers", + "devin-cli-login.test.ts": "providers", "devin-hardening.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", @@ -698,7 +701,6 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", - "context-window-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", @@ -785,9 +787,9 @@ "openai-chat-hardening.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", - "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", + "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", @@ -1037,7 +1039,6 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", - "structure-ssot.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", @@ -1069,6 +1070,7 @@ "storage-worker-teardown-isolate.test.ts": "storage", "stream-aborted-marker.test.ts": "server", "strict-semver.test.ts": "lib", + "structure-ssot.test.ts": "ci-workflows", "subagent-context-staleness.test.ts": "routing", "subagent-defaults.test.ts": "routing", "subagent-fallback-handle-responses.test.ts": "routing", @@ -1197,12 +1199,12 @@ "yaml-fragment-source.test.ts": "config", "z-fulfill.test.ts": "images", "z-handler-activation.test.ts": "images", + "zcode-attribution.test.ts": "adapters", "zcode-client.test.ts": "providers", + "zcode-start-plan.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", - "devin-cli-login.test.ts": "providers", - "devin-cli-authmode-migration.test.ts": "providers" -} + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" +} \ No newline at end of file diff --git a/tests/providers/zcode-start-plan.test.ts b/tests/providers/zcode-start-plan.test.ts index 7c52b3aeb7..b62a74032a 100644 --- a/tests/providers/zcode-start-plan.test.ts +++ b/tests/providers/zcode-start-plan.test.ts @@ -16,17 +16,18 @@ import { } from "../../src/adapters/zcode-identity"; describe("zcode identity attribution eligibility", () => { - test("plan-metered GLM endpoints qualify (coding paths + plan gateway)", () => { - expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/coding/paas/v4")).toBe(true); - expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/coding/paas/v4")).toBe(true); + test("plan-metered GLM send URLs qualify (resolved chat/responses paths + plan gateway)", () => { + expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/coding/paas/v4/chat/completions")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions")).toBe(true); expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/v1")).toBe(true); - expect(isZcodePlanMeteredEndpoint("https://zcode.z.ai/api/v1/zcode-plan/anthropic")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages")).toBe(true); + expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/v1/responses")).toBe(true); }); - test("pay-as-you-go and unrelated endpoints do not qualify", () => { - expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/paas/v4")).toBe(false); - expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/paas/v4")).toBe(false); - expect(isZcodePlanMeteredEndpoint("https://api.openai.com/v1")).toBe(false); + test("pay-as-you-go and unrelated send URLs do not qualify", () => { + expect(isZcodePlanMeteredEndpoint("https://open.bigmodel.cn/api/paas/v4/chat/completions")).toBe(false); + expect(isZcodePlanMeteredEndpoint("https://api.z.ai/api/paas/v4/chat/completions")).toBe(false); + expect(isZcodePlanMeteredEndpoint("https://api.openai.com/v1/responses")).toBe(false); expect(isZcodePlanMeteredEndpoint(undefined)).toBe(false); }); @@ -69,7 +70,35 @@ describe("zcode-start-plan gateway body transform", () => { expect(buildStartPlanSystem(undefined)[3]).toBeUndefined(); const blocks = buildStartPlanSystem([{ type: "text", text: "keep" }, { type: "image" as never }]); expect(blocks[3]).toEqual({ type: "text", text: "keep" }); + // The unrecognized image entry is coerced (never dropped), so 3 official + 2 caller. + expect(blocks.length).toBe(5); + }); + + test("the Claude Code identity block injected by the oauth-mode inner adapter is dropped", () => { + const blocks = buildStartPlanSystem([ + { type: "text", text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }, + { type: "text", text: "caller instruction" }, + ]); + const texts = blocks.map(b => b.text); + expect(texts).not.toContain("You are a Claude agent, built on Anthropic's Claude Agent SDK."); + expect(texts).toContain("caller instruction"); + }); + + test("caller system blocks stay present after the official identity blocks (gateway contract)", () => { + // The gateway REQUIRES the official ZCode blocks first (biz 3012 otherwise — extracted + // from the desktop client). The caller's instructions must survive verbatim AFTER them; + // this pins that the prepend never drops or truncates caller content. + const blocks = buildStartPlanSystem("Always answer in Spanish."); + expect(blocks.length).toBe(4); + expect(blocks[0].text).toBe("You are ZCode, an interactive coding agent"); + expect(blocks[3]).toEqual({ type: "text", text: "Always answer in Spanish." }); + }); + + test("unrecognized caller system entries are coerced to text, never dropped", () => { + const blocks = buildStartPlanSystem([{ type: "image", source: { type: "url" } }]); expect(blocks.length).toBe(4); + expect(blocks[3].type).toBe("text"); + expect(blocks[3].text).toContain("image"); }); test("cache_control: strips stray markers, marks only the last message's last block", () => {