Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null` → `undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552))
- **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558))
- **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516))
- **fix(security):** route raw `err.message` through `sanitizeErrorMessage()` in five web executors (`adapta-web`, `deepseek-web`, `perplexity-web`, `qoder`, `veoaifree-web`) and the embeddings + search handlers (Hard Rule #12) — these built error response bodies from the raw upstream/exception message, which could leak internal detail. ([#3494](https://github.com/diegosouzapw/OmniRoute/issues/3494), [#3495](https://github.com/diegosouzapw/OmniRoute/issues/3495))

---

Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/adapta-web.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

const ADAPTA_APP_URL = "https://agent.adapta.one";
const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one";
Expand Down Expand Up @@ -378,7 +379,7 @@ export class AdaptaWebExecutor extends BaseExecutor {
const msg = err instanceof Error ? err.message : String(err);
log?.warn?.("ADAPTA-WEB", `Auth failed: ${msg}`);
return {
response: makeErrorResponse(401, `Adapta auth failed: ${msg}`),
response: makeErrorResponse(401, `Adapta auth failed: ${sanitizeErrorMessage(msg)}`),
url: ADAPTA_STREAM_URL,
headers: {},
transformedBody: body,
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/deepseek-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseToolCallsFromText,
type OpenAIToolCall,
} from "../translator/webTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com";
const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`;
Expand Down Expand Up @@ -1100,7 +1101,7 @@ export class DeepSeekWebExecutor extends BaseExecutor {
}

return {
response: errorResponse(502, `DeepSeek error: ${msg}`),
response: errorResponse(502, `DeepSeek error: ${sanitizeErrorMessage(msg)}`),
url: COMPLETION_URL,
headers: {},
transformedBody: body,
Expand Down
5 changes: 3 additions & 2 deletions open-sse/executors/perplexity-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type TlsFetchResult,
} from "../services/perplexityTlsClient.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
const PPLX_API_VERSION = "client-1.11.0";
Expand Down Expand Up @@ -756,8 +757,8 @@ export class PerplexityWebExecutor extends BaseExecutor {
JSON.stringify({
error: {
message: isTlsUnavail
? `Perplexity TLS client unavailable: ${(err as Error).message}`
: `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`,
? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}`
: `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,
Comment on lines 759 to +761

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the isTlsUnavail branch, casting err as Error and accessing .message directly is unsafe if err is not an instance of Error (or is null/undefined). This could throw a TypeError and crash the handler. It is safer to use the same fallback check as the second branch.

Suggested change
message: isTlsUnavail
? `Perplexity TLS client unavailable: ${(err as Error).message}`
: `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`,
? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}`
: `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,
message: isTlsUnavail
? `Perplexity TLS client unavailable: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`
: `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,

type: "upstream_error",
},
}),
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/qoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../config/providerHeaderProfiles.ts";
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
import { buildCosyHeadersForValidation } from "../services/qoderCli.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

function getAuthToken(credentials: ProviderCredentials): string {
if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) {
Expand Down Expand Up @@ -230,7 +231,7 @@ export class QoderExecutor extends BaseExecutor {
response: new Response(
JSON.stringify({
error: {
message: `Qoder fetch error: ${error.message}`,
message: `Qoder fetch error: ${sanitizeErrorMessage(error.message)}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing error.message directly is unsafe if error is not an instance of Error (e.g., if it is null, undefined, or a string). Consider using a type guard or fallback to prevent potential runtime exceptions.

Suggested change
message: `Qoder fetch error: ${sanitizeErrorMessage(error.message)}`,
message: `Qoder fetch error: ${sanitizeErrorMessage(error instanceof Error ? error.message : String(error))}`,

type: "provider_error",
},
}),
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/veoaifree-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* No auth required. Rate limited to 6 requests/hour per IP.
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

const BASE_URL = "https://veoaifree.com";
const AJAX_URL = `${BASE_URL}/wp-admin/admin-ajax.php`;
Expand Down Expand Up @@ -362,7 +363,7 @@ export class VeoAIFreeWebExecutor extends BaseExecutor {
nonce = await fetchNonce(input.signal);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to get nonce";
return { response: errResp(msg), url: BASE_URL, headers: {}, transformedBody: null };
return { response: errResp(sanitizeErrorMessage(msg)), url: BASE_URL, headers: {}, transformedBody: null };
}

// Extract aspect ratio from system prompt or default
Expand Down
3 changes: 2 additions & 1 deletion open-sse/handlers/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs";
import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

interface ClientRawRequest {
endpoint: string;
Expand Down Expand Up @@ -339,7 +340,7 @@ export async function handleEmbedding({
return {
success: false,
status: 502,
error: `Embedding provider error: ${err.message}`,
error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing err.message directly is unsafe if err is not an instance of Error. Consider using a type guard or fallback to prevent potential runtime exceptions.

Suggested change
error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`,
error: `Embedding provider error: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,

};
}
}
5 changes: 3 additions & 2 deletions open-sse/handlers/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import { sanitizeErrorMessage } from "../utils/error.ts";

// ── Types ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1164,7 +1165,7 @@ async function tryZaiMCPProvider(
return {
success: false,
status: isTimeout ? 504 : 502,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing err.message directly is unsafe if err is not an instance of Error. Consider using a type guard or fallback to prevent potential runtime exceptions.

Suggested change
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,

};
}
}
Expand Down Expand Up @@ -1434,7 +1435,7 @@ async function tryProvider(
return {
success: false,
status: isTimeout ? 504 : 502,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing err.message directly is unsafe if err is not an instance of Error. Consider using a type guard or fallback to prevent potential runtime exceptions.

Suggested change
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`,
error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,

};
}
}
30 changes: 1 addition & 29 deletions scripts/check/check-error-helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,7 @@ const SCAN_DIRS = [
// with no utils/error import) and should become a tracked cleanup issue: route the
// message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult().
// Do NOT add new entries without a justification — that defeats the gate.
export const KNOWN_MISSING_ERROR_HELPER = new Set([
// adapta-web: local makeErrorResponse() + `Adapta auth failed: ${msg}` where
// msg = err.message — raw auth/upstream error string in the JSON error body,
// no open-sse/utils/error import. Fix: sanitizeErrorMessage(msg) before forwarding.
"open-sse/executors/adapta-web.ts",
// deepseek-web: local errorResponse() shadow that puts `message` raw into the body,
// fed `DeepSeek error: ${msg}` where msg = err.message — bypasses the canonical
// sanitizer. Fix: route through buildErrorBody()/sanitizeErrorMessage().
"open-sse/executors/deepseek-web.ts",
// perplexity-web: `new Response({ error: { message: `Perplexity connection failed:
// ${err.message}` }})` (multi-line envelope) for TLS/connection failures — raw
// err.message in the client error body, no sanitizer import.
"open-sse/executors/perplexity-web.ts",
// qoder: `response: new Response({ error: { message: `Qoder fetch error:
// ${error.message}` }})` — raw error.message in the returned response body,
// no sanitizer import.
"open-sse/executors/qoder.ts",
// veoaifree-web: local errResp(msg) on nonce-fetch failure where msg = err.message —
// raw error string in the response body, no sanitizer import.
"open-sse/executors/veoaifree-web.ts",
// embeddings handler: `return { success: false, status: 502, error: `Embedding
// provider error: ${err.message}` }` — raw err.message in the result error field,
// no sanitizer import. (The saveCallLog `error: err.message` rows are internal and
// correctly NOT what is frozen here.)
"open-sse/handlers/embeddings.ts",
// search handler: `return { …, error: `Search provider …: ${err.message}` }` — raw
// err.message in the result error field, no sanitizer import.
"open-sse/handlers/search.ts",
]);
export const KNOWN_MISSING_ERROR_HELPER = new Set([]);

// Import specifiers that count as "uses the error helper" (path ends in utils/error).
const ERROR_HELPER_IMPORT =
Expand Down