diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9debd383..1317176276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Thanks to everyone whose work landed in v3.8.12: - **sse/groq:** non-reasoning Groq models (`llama-3.3-70b-versatile`, `llama-4-scout`) are now flagged `supportsReasoning: false`, so `reasoning_effort` / `output_config.effort` / `thinking` are stripped before dispatch instead of being forwarded and rejected with HTTP 400 — fixes the Claude Code → Groq regression of #764 ([#3258](https://github.com/diegosouzapw/OmniRoute/issues/3258)) - **api/images:** `POST /v1/images/edits` to a custom OpenAI-compatible provider no longer forwards an empty `model`. The multipart body is now built as a `Buffer` with an explicit boundary instead of a global `FormData` — the patched undici `fetch` serialized a native `FormData` as the literal string `[object FormData]` (text/plain), dropping every field including `model` ([#3273](https://github.com/diegosouzapw/OmniRoute/issues/3273)) - **api/webhooks:** webhook URLs may now target a private/internal address (e.g. `192.168.x`, a docker-internal host) when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` — the webhook guard reuses the same explicit opt-in as private provider URLs (default OFF; protocol and embedded-credential checks stay unconditional). Cloud-metadata / link-local endpoints (`169.254.169.254`, `metadata.google.internal`, `100.100.100.200`, `169.254.0.0/16`) are blocked **unconditionally** even with the opt-in on, and the webhook test endpoint redacts the upstream response body for private targets (no SSRF→IAM-credential pivot, no content exfiltration) ([#3269](https://github.com/diegosouzapw/OmniRoute/issues/3269)) +- **sse/qoder:** a valid Qoder Personal Access Token is no longer wrongly reported as "expired" when the Cosy validation endpoint returns a generic `Internal Server Error` (HTTP 500). A Cosy 500 only marks the PAT invalid when its body carries an explicit auth signal; a generic server fault now falls back to the #1391 valid-bypass rule ([#3247](https://github.com/diegosouzapw/OmniRoute/issues/3247)) --- diff --git a/open-sse/services/qoderCli.ts b/open-sse/services/qoderCli.ts index 09c052097b..f2481cac38 100644 --- a/open-sse/services/qoderCli.ts +++ b/open-sse/services/qoderCli.ts @@ -503,18 +503,24 @@ export async function validateQoderCliPat({ return { valid: true, error: null, unsupported: false }; } - // Treat 5xx as valid bypass to prevent false negatives from legacy Qoder APIs (issue #1391) + // Treat 5xx as a valid bypass to prevent false negatives from legacy Qoder APIs (#1391). + // A Cosy `{"success":false}` 500 is ambiguous: it can be a genuine auth rejection OR a + // transient/generic upstream "Internal Server Error". Only mark the PAT invalid when the + // body carries an EXPLICIT auth signal — a generic 500 is a server fault, not an auth + // verdict, so a working PAT must not be reported as expired (#3247, narrowing #2860). if (res.status >= 500) { - const isCosyAppError = - /"success"\s*:\s*false/.test(errorDetail) && - (/"msgCode"\s*:\s*500/.test(errorDetail) || /internal\s*server\s*error/i.test(errorDetail)); + const isCosyResponse = /"success"\s*:\s*false/.test(errorDetail); + const hasAuthSignal = + /(unauthorized|forbidden|expired|revoked|not\s*authorized|permission\s*denied|access\s*denied|invalid\s*(?:token|credential|api[\s_-]*key)|token\s*(?:invalid|expired|revoked))/i.test( + errorDetail + ); - if (isCosyAppError) { + if (isCosyResponse && hasAuthSignal) { return { valid: false, error: - `Authentication failed (HTTP ${res.status}). The Qoder Cosy server returned an Internal Server Error. ` + - "This typically indicates that your Personal Access Token is invalid, expired, or not authorized. " + + `Authentication failed (HTTP ${res.status}). The Qoder Cosy server rejected the token ` + + "as invalid, expired, or not authorized. " + "Please check your token at https://qoder.com/account/integrations." + (errorDetail ? ` Server response: ${errorDetail}` : ""), unsupported: false, diff --git a/tests/unit/qoder-cli.test.ts b/tests/unit/qoder-cli.test.ts index c4a45cb01c..0928d72527 100644 --- a/tests/unit/qoder-cli.test.ts +++ b/tests/unit/qoder-cli.test.ts @@ -365,37 +365,59 @@ test("validateQoderCliPat treats 5xx HTTP failures as valid bypass", async () => } }); -test("validateQoderCliPat rejects 500 HTTP failures if response is Cosy app-level auth error", async () => { +// #3247: a generic Cosy 500 (`{"success":false,...,"msgCode":500,"message":"Internal +// Server Error"}`) is a SERVER fault, not a reliable auth verdict — a PAT that works in +// the Qoder CLI was being wrongly marked "expired". Per the older #1391 rule, a generic +// 5xx is now a valid bypass; only an explicit auth signal in the body marks it invalid. +test("validateQoderCliPat treats a generic Cosy 500 (no auth signal) as a valid bypass (#3247)", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { if (String(url).includes("/ping")) return new Response("pong", { status: 200 }); return new Response( - ' { "success" : false, "traceId": "a4e5de61929400b9243b4f6e49756906", "msgCode" : 500 } ', + '{"success":false,"traceId":"a4e5de61929400b9243b4f6e49756906","msgCode":500,"msgInfo":"Internal Server Error","message":"Internal Server Error"}', { status: 500 } ); }; try { - const result = await qoderCli.validateQoderCliPat({ apiKey: "invalid-pat" }); - assert.equal(result.valid, false); - assert.match(result.error!, /Authentication failed \(HTTP 500\)/); + const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-valid-token" }); + assert.equal(result.valid, true); + assert.match(result.error!, /treating PAT as valid/); } finally { globalThis.fetch = originalFetch; } }); -test("validateQoderCliPat rejects 500 HTTP failures using regex for Internal Server Error with whitespace", async () => { +test("validateQoderCliPat treats a generic 'Internal Server Error' 500 as a valid bypass (#3247)", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (String(url).includes("/ping")) return new Response("pong", { status: 200 }); + return new Response(' { "success" : false, "error": "Internal Server Error" } ', { + status: 500, + }); + }; + + try { + const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-valid-token" }); + assert.equal(result.valid, true); + assert.match(result.error!, /treating PAT as valid/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateQoderCliPat still rejects a Cosy 500 that carries an explicit auth signal (#2860)", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { if (String(url).includes("/ping")) return new Response("pong", { status: 200 }); return new Response( - ' { "success" : false, "error": "Internal Server Error" } ', + '{"success":false,"msgCode":500,"message":"token invalid or unauthorized"}', { status: 500 } ); }; try { - const result = await qoderCli.validateQoderCliPat({ apiKey: "invalid-pat" }); + const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-bad-token" }); assert.equal(result.valid, false); assert.match(result.error!, /Authentication failed \(HTTP 500\)/); } finally {