From 04d44f62622c01b666b30970ce91d3558beb9818 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 18 May 2026 17:42:09 -0300 Subject: [PATCH] fix(security): sanitize error messages, fix ReDoS patterns, harden OAuth callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error message sanitization (Hard Rule #12): - claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts - imageGeneration, musicGeneration, videoGeneration handlers: import sanitizeErrorMessage and replace all err.message in return values - veoaifree-web executor: replace raw upstream response data in errResp() calls with static strings OAuth callback page (callback/page.tsx): - Remove useSearchParams/Suspense dependency that caused hydration failures in popup windows navigating back from Google OAuth (COOP header severs opener) - Use window.location.search directly in useEffect with three send methods: postMessage, BroadcastChannel, localStorage - Fix postMessage target from "*" to window.location.origin (semgrep finding) - Move setCurrentUrl call to manual-only branch to avoid unnecessary renders copilot-web executor: - Move accessToken from WebSocket URL query string to Authorization header (avoids credential exposure in server logs) - Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry CodeQL ReDoS fixes (js/polynomial-redos #233-240): - Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations) with index-based loop (O(n) time, single final slice) in: bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider Gemini OAuth: - mapTokens: add idToken field to fix "missing id_token" export error --- @omniroute/opencode-provider/src/index.ts | 4 +- bin/cli/api.mjs | 7 +- open-sse/executors/copilot-web.ts | 26 ++-- open-sse/executors/veoaifree-web.ts | 6 +- open-sse/handlers/imageGeneration.ts | 61 +++++++--- open-sse/handlers/musicGeneration.ts | 21 +++- open-sse/handlers/videoGeneration.ts | 21 +++- .../[id]/claude-auth/export/route.ts | 3 +- .../providers/[id]/codex-auth/export/route.ts | 3 +- .../[id]/gemini-cli-auth/export/route.ts | 3 +- src/app/callback/page.tsx | 114 ++++++++---------- src/app/docs/lib/docs-auto-generated.ts | 6 +- src/lib/cli-helper/config-generator/claude.ts | 4 +- src/lib/cli-helper/config-generator/cline.ts | 4 +- src/lib/cli-helper/config-generator/codex.ts | 4 +- .../cli-helper/config-generator/continue.ts | 4 +- .../cli-helper/config-generator/kilocode.ts | 4 +- .../cli-helper/config-generator/opencode.ts | 4 +- src/lib/oauth/providers/gemini.ts | 1 + 19 files changed, 185 insertions(+), 115 deletions(-) diff --git a/@omniroute/opencode-provider/src/index.ts b/@omniroute/opencode-provider/src/index.ts index 4b1e3a6f63..4e2f070a19 100644 --- a/@omniroute/opencode-provider/src/index.ts +++ b/@omniroute/opencode-provider/src/index.ts @@ -193,7 +193,9 @@ export function normalizeBaseURL(rawBaseURL: string): string { ); } let base = trimmed; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); return base + "/v1"; } diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index 62564f0103..30a7feec60 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -45,9 +45,10 @@ export function getBaseUrl(opts = {}) { } function stripTrailingSlash(value) { - let s = String(value); - while (s.endsWith("/")) s = s.slice(0, -1); - return s; + const s = String(value); + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 47) end--; + return end === s.length ? s : s.slice(0, end); } function resolveUrl(path, opts) { diff --git a/open-sse/executors/copilot-web.ts b/open-sse/executors/copilot-web.ts index a668c63126..e50a0b8af7 100644 --- a/open-sse/executors/copilot-web.ts +++ b/open-sse/executors/copilot-web.ts @@ -117,6 +117,7 @@ const sessionPool = new Map(); let sessionRotationCount = 0; const MIN_REMAINING_TURNS = 5; const MAX_ROTATIONS = 1000; +const MAX_POOL_SIZE = 100; // ─── Executor ─────────────────────────────────────────────────────────────── @@ -150,6 +151,10 @@ export class CopilotWebExecutor extends BaseExecutor { } const session = await this.createSession(accessToken, signal); + // Evict oldest entry if pool is at capacity (Map preserves insertion order) + if (sessionPool.size >= MAX_POOL_SIZE) { + sessionPool.delete(sessionPool.keys().next().value!); + } sessionPool.set(poolKey, session); sessionRotationCount++; return session; @@ -214,11 +219,8 @@ export class CopilotWebExecutor extends BaseExecutor { accessToken?: string, signal?: AbortSignal ): Promise> { - // Build WebSocket URL with optional auth - let wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`; - if (accessToken) { - wsUrl += `&accessToken=${encodeURIComponent(accessToken)}`; - } + // Build WebSocket URL without credentials in query string + const wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`; return new ReadableStream({ start: async (controller) => { @@ -261,13 +263,23 @@ export class CopilotWebExecutor extends BaseExecutor { signal?.addEventListener("abort", () => abort("Request aborted"), { once: true }); try { - // Use Node.js built-in WebSocket if available, else dynamic import + // Use Node.js built-in WebSocket if available, else dynamic import. + // Pass the access token via Authorization header (not URL) to avoid + // credential exposure in server logs. let WS = globalThis.WebSocket; if (!WS) { // @ts-ignore — ws module has no type declarations in this project WS = (await import("ws")).default as unknown as typeof WebSocket; + if (accessToken) { + // @ts-ignore — ws module supports headers option in second arg + ws = new WS(wsUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) as WebSocket; + } + } + if (!ws) { + ws = new WS(wsUrl) as WebSocket; } - ws = new WS(wsUrl) as WebSocket; const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS); diff --git a/open-sse/executors/veoaifree-web.ts b/open-sse/executors/veoaifree-web.ts index 5f43b8abc1..dc631bedb0 100644 --- a/open-sse/executors/veoaifree-web.ts +++ b/open-sse/executors/veoaifree-web.ts @@ -82,7 +82,7 @@ async function handleVideo(nonce: string, prompt: string, aspectRatio: string): }); const sceneData = genResult.trim(); if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) { - return errResp(`Video generation failed: ${sceneData}`); + return errResp("Video generation failed"); } // Poll @@ -123,7 +123,7 @@ async function handleImage(nonce: string, prompt: string, aspectRatio: string): }); const trimmed = result.trim(); if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) { - return errResp(`Image generation failed: ${trimmed}`); + return errResp("Image generation failed"); } // Response is comma-separated base64 PNGs or URLs const parts = trimmed @@ -191,7 +191,7 @@ async function handleTTS(prompt: string, voice?: string, lang?: string): Promise } catch { /* not JSON */ } - return errResp(`TTS unexpected response: ${data.slice(0, 200)}`); + return errResp("TTS unexpected response format"); } async function handleEnhance(nonce: string, prompt: string): Promise { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 5dbb3eff02..37558735e6 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -38,6 +38,7 @@ import { extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +import { sanitizeErrorMessage } from "../utils/error.ts"; interface KieImageOptions { model: string; @@ -722,7 +723,11 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden requestBody: logRequestBody, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -1301,7 +1306,7 @@ async function handleFalAIImageGeneration({ model, status: 502, startTime, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }); } } @@ -1491,7 +1496,7 @@ async function handleStabilityAIImageGeneration({ model, status: 502, startTime, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }); } } @@ -1610,7 +1615,7 @@ async function handleBlackForestLabsImageGeneration({ model, status: 502, startTime, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }); } } @@ -1685,7 +1690,7 @@ async function handleRecraftImageGeneration({ model, status: 502, startTime, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }); } } @@ -1773,7 +1778,7 @@ async function handleTopazImageGeneration({ model, status: 502, startTime, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }); } } @@ -2353,7 +2358,7 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { return { success: false, status: 502, - error: `Image provider error: ${err.message}`, + error: sanitizeErrorMessage(err) || "Image provider error", }; } } @@ -2448,7 +2453,11 @@ async function handleHyperbolicImageGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -2702,7 +2711,11 @@ async function handleNanoBananaImageGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -2894,7 +2907,11 @@ async function handleSDWebUIImageGeneration({ model, provider, providerConfig, b duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -2996,7 +3013,11 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -3104,7 +3125,11 @@ async function handleHaiperImageGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -3232,7 +3257,11 @@ async function handleLeonardoImageGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } @@ -3318,7 +3347,11 @@ async function handleIdeogramImageGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Image provider error", + }; } } diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 6528f3ec6d..9a32cc7001 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -24,6 +24,7 @@ import { } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; function normalizeKieSunoModel(model: string): string { const map: Record = { @@ -219,7 +220,11 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Music provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Music provider error", + }; } } @@ -369,7 +374,7 @@ async function handleKieMusicGeneration({ return { success: false, status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, - error: `Music provider error: ${err instanceof Error ? err.message : String(err)}`, + error: sanitizeErrorMessage(err) || "Music provider error", }; } } @@ -487,7 +492,11 @@ async function handleSunoMusicGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Music provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Music provider error", + }; } } @@ -598,6 +607,10 @@ async function handleUdioMusicGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Music provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Music provider error", + }; } } diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 301268ac85..103b364215 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -30,6 +30,7 @@ import { extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../utils/error.ts"; /** * Handle video generation request @@ -200,7 +201,11 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Video provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; } } @@ -288,7 +293,11 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Video provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; } } @@ -419,7 +428,7 @@ async function handleKieVideoGeneration({ return { success: false, status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, - error: `Video provider error: ${err instanceof Error ? err.message : String(err)}`, + error: sanitizeErrorMessage(err) || "Video provider error", }; } } @@ -619,7 +628,11 @@ async function handleRunwayVideoGeneration({ duration: Date.now() - startTime, error: err.message, }).catch(() => {}); - return { success: false, status: 502, error: `Video provider error: ${err.message}` }; + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; } } diff --git a/src/app/api/providers/[id]/claude-auth/export/route.ts b/src/app/api/providers/[id]/claude-auth/export/route.ts index 96d145ab1c..e7e7b75dde 100644 --- a/src/app/api/providers/[id]/claude-auth/export/route.ts +++ b/src/app/api/providers/[id]/claude-auth/export/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { buildClaudeAuthFile, ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; function toErrorResponse(error: unknown) { if (error instanceof ClaudeAuthFileError) { @@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) { ); } - const message = error instanceof Error ? error.message : "Failed to export Claude auth file"; + const message = sanitizeErrorMessage(error) || "Failed to export Claude auth file"; return NextResponse.json({ error: message }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/codex-auth/export/route.ts b/src/app/api/providers/[id]/codex-auth/export/route.ts index 46d9437634..19274ec139 100644 --- a/src/app/api/providers/[id]/codex-auth/export/route.ts +++ b/src/app/api/providers/[id]/codex-auth/export/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; function toErrorResponse(error: unknown) { if (error instanceof CodexAuthFileError) { @@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) { ); } - const message = error instanceof Error ? error.message : "Failed to export Codex auth file"; + const message = sanitizeErrorMessage(error) || "Failed to export Codex auth file"; return NextResponse.json({ error: message }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts b/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts index b5466f2efe..3ea1f251c6 100644 --- a/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts +++ b/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; function toErrorResponse(error: unknown) { if (error instanceof GeminiAuthFileError) { @@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) { ); } - const message = error instanceof Error ? error.message : "Failed to export Gemini auth file"; + const message = sanitizeErrorMessage(error) || "Failed to export Gemini auth file"; return NextResponse.json({ error: message }, { status: 500 }); } diff --git a/src/app/callback/page.tsx b/src/app/callback/page.tsx index 1874bedd42..1e5600288e 100644 --- a/src/app/callback/page.tsx +++ b/src/app/callback/page.tsx @@ -1,23 +1,30 @@ "use client"; import { useTranslations } from "next-intl"; - -import { Suspense, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; /** - * OAuth Callback Page Content + * OAuth Callback Page + * + * Reads URL params via window.location.search (not useSearchParams) to avoid + * the Next.js Suspense boundary requirement, which can delay hydration in popup + * windows that navigate back from a cross-origin OAuth page (e.g. Google). + * Sends the callback data back via three methods in order of reliability: + * 1. postMessage to window.opener (may be null after COOP cross-origin nav) + * 2. BroadcastChannel (same-origin, works across browsing context groups) + * 3. localStorage storage event (works across browsing context groups) */ -function CallbackContent() { - const searchParams = useSearchParams(); - const [status, setStatus] = useState("processing"); +export default function CallbackPage() { + const [status, setStatus] = useState<"processing" | "success" | "done" | "manual">("processing"); + const [currentUrl, setCurrentUrl] = useState(""); const t = useTranslations("auth"); useEffect(() => { - const code = searchParams.get("code"); - const state = searchParams.get("state"); - const error = searchParams.get("error"); - const errorDescription = searchParams.get("error_description"); + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const state = params.get("state"); + const error = params.get("error"); + const errorDescription = params.get("error_description"); const callbackData = { code, @@ -29,23 +36,23 @@ function CallbackContent() { let sent = false; - // Check if this callback is from expected origin/port - const expectedOrigins = [ - window.location.origin, // Same origin (for most providers) - "http://localhost:1455", // Codex specific port - ]; - - // Method 1: postMessage to opener (popup mode) + // Method 1: postMessage to opener (popup mode). + // May be null when Google OAuth's COOP header severs the opener reference. if (window.opener) { try { - window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev + // Target this origin specifically — popup mode is only used when isTrueLocalhost, + // so the opener is always on the same origin as the callback page. + window.opener.postMessage( + { type: "oauth_callback", data: callbackData }, + window.location.origin + ); sent = true; } catch (e) { console.log("postMessage failed:", e); } } - // Method 2: BroadcastChannel (same origin tabs) + // Method 2: BroadcastChannel — works across browsing context groups for same origin. try { const channel = new BroadcastChannel("oauth_callback"); channel.postMessage(callbackData); @@ -55,7 +62,8 @@ function CallbackContent() { console.log("BroadcastChannel failed:", e); } - // Method 3: localStorage event (fallback) + // Method 3: localStorage — triggers storage event in all same-origin windows, + // regardless of browsing context group isolation from COOP. try { localStorage.setItem( "oauth_callback", @@ -67,25 +75,25 @@ function CallbackContent() { } if (sent && (code || error)) { - // Use setTimeout to avoid synchronous setState in effect - setTimeout(() => { - // Only auto-close if opened as popup (has opener) — remote access keeps tab open - if (window.opener) { - setStatus("success"); - setTimeout(() => { - window.close(); - // If can't close (not a popup), show success message - setTimeout(() => setStatus("done"), 500); - }, 1500); - } else { - // Opened as new tab (remote access) — show URL for manual copy - setStatus("done"); - } - }, 0); + if (window.opener) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initialization effect, window-only + setStatus("success"); + setTimeout(() => { + window.close(); + // If close is prevented (browser policy), fall through to manual close prompt. + setTimeout(() => setStatus("done"), 500); + }, 1500); + } else { + // Opened as new tab or opener severed by COOP — show close prompt. + setStatus("done"); + } } else { - setTimeout(() => setStatus("manual"), 0); + // No code/error in URL or all send methods failed — show URL for manual copy. + // Batch the URL and status update so they render together (React 18 auto-batching). + setCurrentUrl(window.location.href); + setStatus("manual"); } - }, [searchParams]); + }, []); return (
@@ -124,9 +132,7 @@ function CallbackContent() {

{t("copyUrl")}

{t("copyUrlManual")}

- - {typeof window !== "undefined" ? window.location.href : ""} - + {currentUrl}
)} @@ -134,29 +140,3 @@ function CallbackContent() {
); } - -/** - * OAuth Callback Page - * Receives callback from OAuth providers and sends data back via multiple methods - */ -export default function CallbackPage() { - const t = useTranslations("auth"); - return ( - -
-
- - progress_activity - -
-

{t("loading")}

-
- - } - > - -
- ); -} diff --git a/src/app/docs/lib/docs-auto-generated.ts b/src/app/docs/lib/docs-auto-generated.ts index 09f3e36606..0c104756d2 100644 --- a/src/app/docs/lib/docs-auto-generated.ts +++ b/src/app/docs/lib/docs-auto-generated.ts @@ -704,13 +704,13 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ fileName: "reference/PROVIDER_REFERENCE.md", section: "Reference", content: - "Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-13 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn", + "Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-17 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn", headings: [ "Categories", "Free Tier (OAuth-first or no-key) (5)", "OAuth Providers (11)", - "Web Cookie Providers (5)", - "API Key Providers (paid / paid-with-free-credits) (123)", + "Web Cookie Providers (6)", + "API Key Providers (paid / paid-with-free-credits) (122)", "Local Providers (10)", "Search Providers (11)", "Audio-only Providers (7)", diff --git a/src/lib/cli-helper/config-generator/claude.ts b/src/lib/cli-helper/config-generator/claude.ts index 92bb8a7944..2b2490690f 100644 --- a/src/lib/cli-helper/config-generator/claude.ts +++ b/src/lib/cli-helper/config-generator/claude.ts @@ -9,7 +9,9 @@ export function generateClaudeConfig(options: { model?: string; }): string { let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const model = options.model || "claude-3-5-sonnet-20241022"; diff --git a/src/lib/cli-helper/config-generator/cline.ts b/src/lib/cli-helper/config-generator/cline.ts index c0d37e53b9..add8089501 100644 --- a/src/lib/cli-helper/config-generator/cline.ts +++ b/src/lib/cli-helper/config-generator/cline.ts @@ -9,7 +9,9 @@ export function generateClineConfig(options: { model?: string; }): string { let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const config = { diff --git a/src/lib/cli-helper/config-generator/codex.ts b/src/lib/cli-helper/config-generator/codex.ts index ca5a1dc815..dedd6b0341 100644 --- a/src/lib/cli-helper/config-generator/codex.ts +++ b/src/lib/cli-helper/config-generator/codex.ts @@ -18,7 +18,9 @@ export async function generateCodexConfig(options: { }): Promise { const y = await loadYaml(); let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const config = { diff --git a/src/lib/cli-helper/config-generator/continue.ts b/src/lib/cli-helper/config-generator/continue.ts index 4eeb6d9c85..4018fc1ca5 100644 --- a/src/lib/cli-helper/config-generator/continue.ts +++ b/src/lib/cli-helper/config-generator/continue.ts @@ -18,7 +18,9 @@ export async function generateContinueConfig(options: { }): Promise { const y = await loadYaml(); let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const config = { diff --git a/src/lib/cli-helper/config-generator/kilocode.ts b/src/lib/cli-helper/config-generator/kilocode.ts index 87596d88d4..1d9e0c28e3 100644 --- a/src/lib/cli-helper/config-generator/kilocode.ts +++ b/src/lib/cli-helper/config-generator/kilocode.ts @@ -9,7 +9,9 @@ export function generateKilocodeConfig(options: { model?: string; }): string { let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const config = { diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index 0d4667a90b..9849c1c8da 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -9,7 +9,9 @@ export function generateOpencodeConfig(options: { model?: string; }): string { let base = options.baseUrl; - while (base.endsWith("/")) base = base.slice(0, -1); + let end = base.length; + while (end > 0 && base[end - 1] === "/") end--; + base = end < base.length ? base.slice(0, end) : base; if (base.endsWith("/v1")) base = base.slice(0, -3); const config = { diff --git a/src/lib/oauth/providers/gemini.ts b/src/lib/oauth/providers/gemini.ts index f710badd1a..66c28050d7 100644 --- a/src/lib/oauth/providers/gemini.ts +++ b/src/lib/oauth/providers/gemini.ts @@ -90,6 +90,7 @@ export const gemini = { mapTokens: (tokens, extra) => ({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, + idToken: tokens.id_token ?? null, expiresIn: tokens.expires_in, scope: tokens.scope, email: extra?.userInfo?.email,