diff --git a/CHANGELOG.md b/CHANGELOG.md index f69979e282..82c4624eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🐛 Fixed - **fix(intelligence): run pricing + models.dev sync from the live startup path** — like the Arena ELO sync (v3.8.24), the external **pricing sync** (`PRICING_SYNC_ENABLED`) and the **models.dev capability sync** (Settings → AI toggle) were only initialized from `server-init.ts`, which the Next standalone runtime never executes — and models.dev had no caller at all. Their toggles were inert in production. Both are now initialized from `instrumentation-node.ts` (self-gated, opt-in preserved, non-blocking, never fatal). (thanks @diegosouzapw) +- **fix(grok-web): clearer 403 message for anti-bot / IP-reputation blocks** — a Grok Web subscription validating from a flagged datacenter/VPS IP got a 403 that read like an invalid cookie, sending users to chase a cookie that was actually fine. A non-auth 403 (Cloudflare challenge / anti-bot body) now returns a message stating the cookie is likely OK and the block is IP-reputation-based — retry from a residential IP or configure a proxy (auth-shaped 403s keep the re-paste guidance). ([#3474](https://github.com/diegosouzapw/OmniRoute/issues/3474) — thanks @friedtofu1608) - **fix(db): make the mass-pending-migrations safety threshold env-overridable** — restoring a backup DB from an older version could trip "Detected N pending migrations … threshold is 50" with no way to override the hardcoded `50`. The threshold is now configurable via `OMNIROUTE_MAX_PENDING_MIGRATIONS` (resolved at startup; `0` disables the check). ([#3416](https://github.com/diegosouzapw/OmniRoute/issues/3416) — thanks @samuraiIT) - **test(proxy): cover the Vercel-relay `proxyFetch` path** — net-new tests for `buildVercelRelayHeaders` and the `vercel`-type relay short-circuit (`x-relay-target`/`-path`/`-auth`, TCP-skip, missing-auth fail-closed), closing one of the deferred test gaps tracked in [#2743](https://github.com/diegosouzapw/OmniRoute/issues/2743). (thanks @diegosouzapw) - **fix(cli): surface `omniroute runtime repair` in the native-module error messages** — after a Node major upgrade, `better-sqlite3`'s prebuilt binary mismatches the ABI and the service can crash-loop; the error only mentioned `npm rebuild better-sqlite3` (which fails for global / no-toolchain installs). The startup + SQLite error hints now also point to the existing self-heal command `omniroute runtime repair` (rebuilds into a user-writable runtime), and a top-level `omniroute repair` alias was added. ([#3476](https://github.com/diegosouzapw/OmniRoute/issues/3476) — thanks @Rahulsharma0810) diff --git a/file-size-baseline.json b/file-size-baseline.json index 0e7a9f926a..cecf5cf59e 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -87,7 +87,7 @@ "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4302, + "src/lib/providers/validation.ts": 4348, "src/lib/tailscaleTunnel.ts": 1189, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 949, @@ -116,5 +116,6 @@ "_rebaseline_2026_06_13_3786_agy_fallback": "Re-baseline #3786 (agy Pro-family upstream-id fallback chain): antigravity.ts 1572→1649 (+77). Crescimento = split de execute() em driver + executeOnce(modelIdOverride) para retentar ids alternativos no 400 (gemini-3.1-pro-high→gemini-pro-agent→gemini-3-pro-high), threading do override em transformRequest/cleanModelName. Lógica coesa de retry no executor — não é bloco movível (chama this.executeOnce). A parte pura (ANTIGRAVITY_PRO_FALLBACK_CHAINS + getAntigravityModelFallbacks) ficou em antigravityModelAliases.ts. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.", "_rebaseline_2026_06_13_3782_hide_persist": "Re-baseline #3782 (preservar modelos eye-hidden no auto-sync): models.ts 1132→1180 (+48). Crescimento = flag distinto isDeleted em ModelCompatOverride/ModelCompatPatch + handling em mergeModelCompatOverride + helper getModelIsDeleted, para separar 'deletado' (trash, dropado no re-sync #3199) de 'oculto' (eye toggle, preservado). Lógica coesa de visibilidade no módulo db; não-extraível. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.", "_rebaseline_2026_06_13_3758_chat_early_eof": "Re-baseline #3758 (#3817 mergeado): chat.ts 1392→1425 (+33). Crescimento = retry bounded de STREAM_EARLY_EOF no handleSingleModelChat (contador streamEarlyEofRetries + bloco de retry guardado por shouldRetryStreamEarlyEof). Lógica coesa no handler de chat; não-extraível. Reconciliação tardia — o bump foi esquecido no PR do fix (o de antigravity/models foi feito).", - "_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível." + "_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível.", + "_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível." } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index de9200bd13..a250836c28 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2905,6 +2905,35 @@ async function validateQwenWebProvider({ apiKey }: any) { } } +/** + * Heuristic for a Grok 403 that is an anti-bot / IP-reputation block rather than + * a genuine upstream API error (issue #3474). + * + * Returns true when the body reads like an anti-bot rejection — Grok's literal + * "Request rejected by anti-bot rules." text, or a bare/non-structured forbidden + * body that carries no parseable upstream `error.message`. Returns false for a + * structured upstream API error (e.g. `{"error":{"message":"Model is not found"}}`), + * which must keep surfacing its body to the user/maintainer. + * + * Callers should run `isCloudflareChallenge()` first; this covers the non-HTML + * anti-bot cases that Cloudflare-challenge detection does not. + */ +function isGrokAntiBotBlock(body: string | null | undefined): boolean { + const text = (body || "").trim(); + if (!text) return true; // empty 403 body — pre-auth block, treat as anti-bot + if (/anti-bot|forbidden|access denied|blocked|rate.?limit/i.test(text)) return true; + // A structured upstream API error has a parseable JSON `error.message`; if one + // is present this is a real upstream error, not an anti-bot block. + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed?.error?.message === "string") return false; + } catch { + // Non-JSON 403 body with no recognizable structure → treat as anti-bot block. + return true; + } + return false; +} + async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) { try { const token = extractCookieValue(apiKey, "sso"); @@ -3022,17 +3051,34 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an } if (response.status === 403) { - // Grok uses 403 for auth failures, entitlement issues, geo blocks, and - // resource errors. Default-deny: only the auth-shaped 403 gets the - // re-paste hint; anything else surfaces the upstream body so the user - // (or maintainer, if upstream renames the probe model) sees the real - // cause instead of a misleading "valid" verdict. + // Grok uses 403 for auth failures, entitlement issues, geo blocks, + // anti-bot/IP-reputation rejections, and resource errors. Classify before + // messaging — a misleading "invalid cookie" verdict on an IP-reputation + // block (issue #3474) sends users chasing a cookie that is actually fine. + // + // 1. Auth-shaped → the cookie/session is the problem; re-paste it. if (/invalid-credentials|unauthenticated|unauthorized/i.test(errorDetail)) { return { valid: false, error: "Invalid SSO cookie — re-paste from grok.com DevTools → Cookies → sso", }; } + // 2. Anti-bot / Cloudflare / IP-reputation block → the cookie is likely + // fine; the request was rejected before auth was even evaluated. This is + // not code-fixable: the datacenter/VPS IP is flagged. A Cloudflare + // challenge body, Grok's "anti-bot rules" rejection, or a bare/non-JSON + // forbidden body (no structured upstream `error.message`) all map here. + if (isCloudflareChallenge(errorDetail) || isGrokAntiBotBlock(errorDetail)) { + return { + valid: false, + error: + "Grok returned 403 (anti-bot/Cloudflare block). Your sso cookie is likely fine — " + + "this is an IP-reputation block on the request, not an auth failure. Retry from a " + + "residential IP or configure a proxy for grok-web.", + }; + } + // 3. Structured upstream error (e.g. probe model renamed) → surface the body + // so the user/maintainer sees the real cause instead of a wrong verdict. return { valid: false, error: `Grok rejected validation (403)${errorDetail ? `: ${errorDetail.slice(0, 160)}` : ""}`, diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 37d9d41a6e..f4503cb8ab 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -478,14 +478,99 @@ test("grok-web validator: non-auth 403 is reported as failure with upstream body assert.match(result.error || "", /Model is not found/); }); -test("grok-web validator: generic 403 forbidden is rejected, not silently passed", async () => { +test("grok-web validator: generic non-auth 403 maps to IP-reputation guidance, not 'invalid cookie' (#3474)", async () => { __setGrokTlsFetchOverride(async () => { return { status: 403, headers: new Headers(), text: "Forbidden", body: null }; }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "any-cookie" }); assert.equal(result.valid, false); + // A bare/non-auth 403 from grok.com is almost always an anti-bot/IP-reputation + // block — the cookie itself is likely fine. The message must point the user to a + // residential IP or proxy, NOT tell them the cookie is invalid. + assert.match(result.error || "", /residential IP|proxy/i); + assert.doesNotMatch(result.error || "", /invalid SSO cookie/i); +}); + +test("grok-web validator: anti-bot 'Request rejected' 403 maps to IP-reputation guidance (#3474)", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: "Request rejected by anti-bot rules.", + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /residential IP|proxy/i); + // Cookie may be fine — must not claim it is invalid/expired. + assert.doesNotMatch(result.error || "", /invalid SSO cookie|expired/i); +}); + +test("grok-web validator: Cloudflare challenge returned with a 403 status maps to IP guidance (#3474)", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: "Just a moment...", + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "sso=abc" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /residential IP|proxy/i); +}); + +test("grok-web validator: IP-reputation 403 message does not leak a stack/raw error (Hard Rule #12) (#3474)", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: "Request rejected by anti-bot rules.\n at GrokWeb.validate (/app/secret/path.ts:42:13)", + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /residential IP|proxy/i); + assert.ok(!(result.error || "").includes("at GrokWeb.validate")); + assert.ok(!(result.error || "").includes("/app/secret/path.ts")); + assert.ok(!(result.error || "").includes(" at ")); +}); + +test("grok-web validator: structured non-auth 403 (resource error) still surfaces upstream body for maintainers (#3474)", async () => { + // Distinct from an anti-bot block: a genuine upstream API error (e.g. the probe + // model was renamed) carries a structured error.message and must NOT be masked + // by the IP-reputation guidance — the maintainer needs to see the real cause. + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ error: { code: 7, message: "Model is not found", details: [] } }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + assert.equal(result.valid, false); assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.match(result.error || "", /Model is not found/); + assert.doesNotMatch(result.error || "", /residential IP|proxy/i); +}); + +test("grok-web validator: auth-shaped 401 keeps the re-paste/re-authenticate guidance (no regression) (#3474)", async () => { + __setGrokTlsFetchOverride(async () => { + return { status: 401, headers: new Headers(), text: "Unauthorized", body: null }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "expired-cookie" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid SSO cookie|re-paste/i); + assert.doesNotMatch(result.error || "", /residential IP|proxy/i); }); test("grok-web validator: 403 with credential-rejection body is treated as auth-failed", async () => {