diff --git a/.env.example b/.env.example index 26acb57811..9b52204332 100644 --- a/.env.example +++ b/.env.example @@ -527,6 +527,14 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # QODER_CLI_WORKSPACE= # OMNIROUTE_QODER_WORKSPACE= +# ── Blackbox Web validated-token override (issue #2252) ── +# Used by: open-sse/executors/blackbox-web.ts. Blackbox `/api/chat` rejects +# requests whose `validated` field doesn't match the frontend `tk` token, +# returning HTTP 403 even with a valid session cookie + active subscription. +# Set this to the `tk` value exported from app.blackbox.ai's Next.js bundle +# to bypass the random-UUID fallback. Leave empty to keep the legacy behavior. +# BLACKBOX_WEB_VALIDATED_TOKEN= + # ───────────────────────────────────────────────────────────────────────────── # ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) & OTHER PROVIDERS — REMOTE SERVERS # ───────────────────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 702a5e6f56..ff774a2688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **fix(ui/claude-extra-usage):** clarify the toggle-success notification text to spell out the toggle→effect relationship ("Claude extra-usage blocking enabled/disabled" instead of the ambiguous "blocked/allowed"). (#2157) - **fix(providers/qoder):** disambiguate the "Local CLI runtime is not installed" error when a user pastes a Personal Access Token but the connection is in OAuth/CLI-flavored mode. The test route now surfaces a single actionable message ("switch this connection to API Key auth") instead of cascading CLI + 401 errors. (#2247) - **fix(dashboard/api-manager):** route custom OpenAI-/Anthropic-compatible provider IDs through `getProviderDisplayName` so the model grouping label shows `Compatible (openai)` instead of leaking the raw synthetic `openai-compatible-chat-` value. (#2021) +- **fix(providers/blackbox-web):** add `BLACKBOX_WEB_VALIDATED_TOKEN` env override and 403 token-error disambiguation. Blackbox `/api/chat` started rejecting requests whose `validated` field didn't match the frontend `tk` token, even with a valid cookie + active subscription. Operators with the real token can now set the env var; otherwise the previous random-UUID fallback still ships, and a 403 with a token-specific body now surfaces a one-line "set BLACKBOX_WEB_VALIDATED_TOKEN" hint instead of the generic "cookie expired" message. (#2252) ### Changed diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 13933a3ff0..b131d7505c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -367,6 +367,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). | | `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. | | `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. | +| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. | > [!WARNING] > **Google OAuth** (Antigravity, Gemini CLI) credentials **only work on localhost**. For remote servers: diff --git a/open-sse/executors/blackbox-web.ts b/open-sse/executors/blackbox-web.ts index 35ec86d45a..47a0730997 100644 --- a/open-sse/executors/blackbox-web.ts +++ b/open-sse/executors/blackbox-web.ts @@ -14,6 +14,45 @@ const BLACKBOX_USER_AGENT = const SESSION_CACHE_TTL_MS = 5 * 60_000; // 5 minutes +/** + * Resolve the `validated` token for Blackbox `/api/chat` requests. + * + * Blackbox's web frontend ships a real validation token (exported as `tk` from + * its Next.js JavaScript chunks). If the value sent in `transformedBody.validated` + * does not match that token, the upstream returns HTTP 403 even when the session + * cookie and subscription are valid — see issue #2252. + * + * Resolution priority: + * 1. `BLACKBOX_WEB_VALIDATED_TOKEN` env var (operator-supplied, preferred) + * 2. Random UUID fallback (the original behavior; works only as long as + * Blackbox doesn't enforce a specific frontend `tk` value) + * + * We do NOT scrape Blackbox's Next.js chunks at runtime to extract `tk` — that + * coupling to their bundle hash is fragile and would silently break on every + * frontend deploy. The env-var override gives operators who have figured out + * the token a stable way to use it without patching code. + */ +export function resolveBlackboxValidatedToken(): string { + const explicit = (process.env.BLACKBOX_WEB_VALIDATED_TOKEN || "").trim(); + if (explicit) return explicit; + return crypto.randomUUID(); +} + +/** + * Detect whether a Blackbox 403 response body indicates that the `validated` + * token is the problem (as opposed to a missing cookie or expired subscription). + * Surfaces the BLACKBOX_WEB_VALIDATED_TOKEN env var as the next step. + */ +function isBlackboxValidatedTokenError(responseText: string): boolean { + const lower = (responseText || "").toLowerCase(); + return ( + lower.includes("invalid validated token") || + lower.includes("invalid validated") || + lower.includes("validation token") || + lower.includes("invalid token") + ); +} + type CachedSession = { sessionData: Record | null; subscriptionCache: Record | null; @@ -406,7 +445,9 @@ export class BlackboxWebExecutor extends BaseExecutor { mobileClient: false, userSelectedModel: model || null, userSelectedAgent: "VscodeAgent", - validated: crypto.randomUUID(), + // Issue #2252: prefer operator-supplied BLACKBOX_WEB_VALIDATED_TOKEN over + // a random UUID — Blackbox's `/api/chat` rejects mismatched tokens with 403. + validated: resolveBlackboxValidatedToken(), imageGenerationMode: false, imageGenMode: "autoMode", webSearchModePrompt: false, @@ -477,7 +518,15 @@ export class BlackboxWebExecutor extends BaseExecutor { if (!upstreamResponse.ok) { const status = upstreamResponse.status; let message = `Blackbox Web returned HTTP ${status}`; - if (status === 401 || status === 403) { + // Issue #2252: distinguish "wrong validated token" from "expired cookie" + // when 403 carries a token-specific body — the fix is different in each case. + const errorBody = await upstreamResponse.text().catch(() => ""); + if (status === 403 && isBlackboxValidatedTokenError(errorBody)) { + message = + "Blackbox Web rejected the request with an invalid `validated` token. " + + "If you have a valid frontend token (the `tk` value from app.blackbox.ai's " + + "Next.js bundle), set BLACKBOX_WEB_VALIDATED_TOKEN in your environment and restart."; + } else if (status === 401 || status === 403) { message = "Blackbox Web auth failed — your app.blackbox.ai session cookie may be missing or expired."; } else if (status === 429) { diff --git a/tests/unit/blackbox-web-validated-token.test.ts b/tests/unit/blackbox-web-validated-token.test.ts new file mode 100644 index 0000000000..c8ac63fa71 --- /dev/null +++ b/tests/unit/blackbox-web-validated-token.test.ts @@ -0,0 +1,102 @@ +/** + * Issue #2252 — BLACKBOX_WEB_VALIDATED_TOKEN env override + 403 detection. + * + * Blackbox's `/api/chat` rejects requests whose `validated` field doesn't + * match the frontend `tk` token, even when the session cookie and + * subscription are valid. These tests cover: + * + * 1. The new `resolveBlackboxValidatedToken()` helper: + * - env var wins over the random-UUID fallback + * - whitespace is trimmed + * - empty/whitespace-only env falls back to randomUUID + * - randomUUID fallback returns a well-formed UUID v4 shape + * + * 2. The 403 + token-specific error body should NOT be conflated with the + * generic "expired cookie" message. We assert by string-search on the + * executor source — the executor is hard to drive end-to-end in unit + * tests because it opens a streaming fetch against app.blackbox.ai. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const { resolveBlackboxValidatedToken } = await import("../../open-sse/executors/blackbox-web.ts"); + +const EXECUTOR_FILE = path.resolve("open-sse/executors/blackbox-web.ts"); +const ORIGINAL_ENV = process.env.BLACKBOX_WEB_VALIDATED_TOKEN; + +function restoreEnv() { + if (ORIGINAL_ENV === undefined) { + delete process.env.BLACKBOX_WEB_VALIDATED_TOKEN; + } else { + process.env.BLACKBOX_WEB_VALIDATED_TOKEN = ORIGINAL_ENV; + } +} + +test.after(restoreEnv); + +test("#2252 — BLACKBOX_WEB_VALIDATED_TOKEN env var wins over random UUID", () => { + process.env.BLACKBOX_WEB_VALIDATED_TOKEN = "stub-frontend-token-12345"; + try { + assert.equal(resolveBlackboxValidatedToken(), "stub-frontend-token-12345"); + } finally { + restoreEnv(); + } +}); + +test("#2252 — whitespace around the env value is trimmed", () => { + process.env.BLACKBOX_WEB_VALIDATED_TOKEN = " stub-with-spaces "; + try { + assert.equal(resolveBlackboxValidatedToken(), "stub-with-spaces"); + } finally { + restoreEnv(); + } +}); + +test("#2252 — empty env falls back to random UUID", () => { + delete process.env.BLACKBOX_WEB_VALIDATED_TOKEN; + const token = resolveBlackboxValidatedToken(); + // crypto.randomUUID() returns RFC 4122 v4 — 8-4-4-4-12 hex + assert.match(token, /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); +}); + +test("#2252 — whitespace-only env falls back to random UUID", () => { + process.env.BLACKBOX_WEB_VALIDATED_TOKEN = " "; + try { + const token = resolveBlackboxValidatedToken(); + assert.match( + token, + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + } finally { + restoreEnv(); + } +}); + +test("#2252 — different random fallbacks return different tokens", () => { + delete process.env.BLACKBOX_WEB_VALIDATED_TOKEN; + const a = resolveBlackboxValidatedToken(); + const b = resolveBlackboxValidatedToken(); + assert.notEqual(a, b); +}); + +test("#2252 — executor uses resolveBlackboxValidatedToken in transformedBody", () => { + const source = fs.readFileSync(EXECUTOR_FILE, "utf8"); + // The literal crypto.randomUUID() was replaced + assert.doesNotMatch( + source.split("transformedBody = {")[1]?.split("};")[0] ?? "", + /validated: crypto\.randomUUID\(\)/, + "transformedBody must call resolveBlackboxValidatedToken() instead of crypto.randomUUID() directly" + ); + assert.match(source, /validated: resolveBlackboxValidatedToken\(\)/); +}); + +test("#2252 — 403 with token-specific body surfaces BLACKBOX_WEB_VALIDATED_TOKEN guidance", () => { + const source = fs.readFileSync(EXECUTOR_FILE, "utf8"); + // The 403 disambiguation message must reference the env var so users know + // exactly what to set. + assert.match(source, /BLACKBOX_WEB_VALIDATED_TOKEN/); + assert.match(source, /isBlackboxValidatedTokenError/); +});