mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 14:52:22 +03:00
Compare commits
1 Commits
fix/14309-
...
fix/14021-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896503a0de |
@@ -0,0 +1 @@
|
||||
- fix(dashboard): un-gate the Dev Tools sidebar section (Playground, Translator, Search Tools) from Debug Mode so it is discoverable by default (#14021)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): surface the real transport diagnosis (DNS/socket cause) instead of a bare "fetch failed" in provider validation errors (#14309)
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "./proxyDispatcher.ts";
|
||||
import tlsClient, { type TlsFetchOptions, guardTlsFirstByte } from "./tlsClient.ts";
|
||||
import { withUpstreamStatusCapture } from "./upstreamStatusCapture.ts";
|
||||
import { describeFallbackFailure, redactProxyDetailsInMessage } from "./proxyFetchRedaction.ts";
|
||||
import { isProxyReachable } from "@/lib/proxyHealth";
|
||||
import {
|
||||
isControlPlaneProxyDirectFallbackEnabled,
|
||||
@@ -341,6 +340,20 @@ function isWreqProxySupported(proxyUrl: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
|
||||
* upstream transport-error message before it is surfaced. #10032 keeps the
|
||||
* underlying failure reason in the propagated error for diagnosability, but
|
||||
* the raw message can embed the full proxy URL — including userinfo
|
||||
* credentials — which must never bubble into response bodies (#9837, Hard
|
||||
* Rule #12).
|
||||
*/
|
||||
function redactProxyDetailsInMessage(message: string): string {
|
||||
return message
|
||||
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
|
||||
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
|
||||
}
|
||||
|
||||
function sanitizeTransportError(
|
||||
error: unknown,
|
||||
message: string,
|
||||
@@ -895,10 +908,7 @@ async function patchedFetchUnrecorded(
|
||||
continue;
|
||||
}
|
||||
if (hasNonReplayableBody) {
|
||||
const detail = describeFallbackFailure(
|
||||
describeFetchCause(dispatcherError),
|
||||
"skipped: non-replayable request body"
|
||||
);
|
||||
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[skipped: non-replayable request body]`;
|
||||
console.warn(
|
||||
`[ProxyFetch] skipping native fetch fallback for non-replayable body: ${detail}`
|
||||
);
|
||||
@@ -942,10 +952,7 @@ async function patchedFetchUnrecorded(
|
||||
return await _nativeFallback(input, options);
|
||||
} catch (nativeError) {
|
||||
// Surface both dispatcher and native causes immediately.
|
||||
const detail = describeFallbackFailure(
|
||||
describeFetchCause(dispatcherError),
|
||||
describeFetchCause(nativeError)
|
||||
);
|
||||
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
|
||||
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
|
||||
if (nativeError instanceof Error) {
|
||||
(nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Extracted from proxyFetch.ts (frozen file-size baseline — #14309) so the
|
||||
// transport-error diagnostics built there can be redacted without growing
|
||||
// the frozen file.
|
||||
//
|
||||
// #10032 keeps the underlying transport failure reason in the propagated
|
||||
// error for diagnosability, but the raw message can embed a full proxy URL
|
||||
// — including userinfo credentials — which must never bubble into response
|
||||
// bodies (#9837, Hard Rule #12).
|
||||
|
||||
/**
|
||||
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
|
||||
* upstream transport-error message before it is surfaced.
|
||||
*/
|
||||
export function redactProxyDetailsInMessage(message: string): string {
|
||||
return message
|
||||
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
|
||||
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `.proxyFetchDetail` diagnosis for proxyFetch.ts's direct-path
|
||||
* (pooled undici dispatcher + native fetch fallback) branches, redacted the
|
||||
* same way as the proxy-path message (see redactProxyDetailsInMessage above).
|
||||
*/
|
||||
export function describeFallbackFailure(dispatcherCause: string, nativeDetail: string): string {
|
||||
return redactProxyDetailsInMessage(`dispatcher=[${dispatcherCause}] native=[${nativeDetail}]`);
|
||||
}
|
||||
@@ -178,26 +178,6 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* proxyFetch.ts computes a detailed transport diagnosis (DNS/socket error
|
||||
* code, syscall, address) whenever a direct fetch fails on both the pooled
|
||||
* undici dispatcher and the native-fetch fallback, and attaches it to the
|
||||
* thrown error as `.proxyFetchDetail`. safeOutboundFetch's
|
||||
* normalizeFetchFailure() then wraps that error in a SafeOutboundFetchError
|
||||
* whose `.message` is copied from the generic "fetch failed" string and
|
||||
* whose `.cause` is the original error carrying `.proxyFetchDetail`. Without
|
||||
* this, the computed diagnosis never reaches the caller (#14309).
|
||||
*/
|
||||
function extractProxyFetchDetail(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const detail = (cause as Error & { proxyFetchDetail?: unknown }).proxyFetchDetail;
|
||||
return typeof detail === "string" && detail.length > 0 ? detail : undefined;
|
||||
}
|
||||
|
||||
const GENERIC_TRANSPORT_FAILURE_PATTERN = /^fetch failed$/i;
|
||||
|
||||
export function toValidationErrorResult(error: unknown) {
|
||||
let rawMessage: unknown = error || "Validation failed";
|
||||
try {
|
||||
@@ -205,17 +185,6 @@ export function toValidationErrorResult(error: unknown) {
|
||||
} catch {
|
||||
rawMessage = "Validation failed";
|
||||
}
|
||||
try {
|
||||
if (
|
||||
typeof rawMessage === "string" &&
|
||||
GENERIC_TRANSPORT_FAILURE_PATTERN.test(rawMessage.trim())
|
||||
) {
|
||||
const detail = extractProxyFetchDetail(error);
|
||||
if (detail) rawMessage = `Network error: ${detail}`;
|
||||
}
|
||||
} catch {
|
||||
// Diagnostic enrichment is advisory; never let it break error reporting.
|
||||
}
|
||||
const message = sanitizeErrorMessage(rawMessage);
|
||||
let statusCode: number | null = null;
|
||||
let timeout = false;
|
||||
|
||||
@@ -851,7 +851,6 @@ export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [
|
||||
titleKey: "devtoolsSection",
|
||||
titleFallback: "Dev Tools",
|
||||
children: DEVTOOLS_ITEMS,
|
||||
visibility: "debug",
|
||||
},
|
||||
{
|
||||
id: "agentic-features",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Repro for #14309 — "all provider validation fails with 'fetch failed'".
|
||||
//
|
||||
// open-sse/utils/proxyFetch.ts already computes a rich diagnostic string
|
||||
// (dispatcher cause + native-fallback cause, including the real DNS/socket
|
||||
// error code) whenever BOTH the pooled undici dispatcher path AND the
|
||||
// native-fetch fallback fail, and attaches it to the thrown error as
|
||||
// `.proxyFetchDetail` (open-sse/utils/proxyFetch.ts:953-961; proven attached
|
||||
// by the existing tests/unit/proxyfetch-undici-retry.test.ts).
|
||||
//
|
||||
// That thrown error then reaches safeOutboundFetch()'s catch block
|
||||
// (src/shared/network/safeOutboundFetch.ts::normalizeFetchFailure), which
|
||||
// wraps it into a `SafeOutboundFetchError` whose `.message` is copied from
|
||||
// the ORIGINAL error's generic "fetch failed" message and whose `.cause` is
|
||||
// the original error (carrying `.proxyFetchDetail`).
|
||||
//
|
||||
// `toValidationErrorResult()` in src/lib/providers/validation/transport.ts
|
||||
// — the function that turns that thrown error into the JSON body
|
||||
// `/api/providers/validate` sends to the dashboard — only ever reads
|
||||
// `error.message`. It never looks at `error.cause`, so the diagnostic detail
|
||||
// that was carefully computed two layers down is silently discarded before
|
||||
// it ever reaches the user, and the dashboard always shows the bare,
|
||||
// non-actionable "fetch failed" string regardless of the real underlying
|
||||
// cause (DNS failure, connection refused, TLS error, etc.) — exactly what
|
||||
// #14309 reports.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { toValidationErrorResult } from "../../src/lib/providers/validation/transport";
|
||||
import { SafeOutboundFetchError } from "../../src/shared/network/safeOutboundFetch";
|
||||
|
||||
test("toValidationErrorResult should surface the computed proxyFetchDetail diagnosis (via error.cause) instead of the generic 'fetch failed' message (#14309)", () => {
|
||||
// Mirrors exactly what proxyFetch.ts's native-fallback-also-failed branch
|
||||
// attaches to the original error (open-sse/utils/proxyFetch.ts:955-958).
|
||||
const nativeError = new Error("fetch failed") as Error & { proxyFetchDetail?: string };
|
||||
nativeError.proxyFetchDetail =
|
||||
"dispatcher=[fetch failed code=UND_ERR_SOCKET] native=[getaddrinfo ENOTFOUND api.mistral.ai code=ENOTFOUND syscall=getaddrinfo]";
|
||||
|
||||
// Mirrors exactly what safeOutboundFetch.ts's normalizeFetchFailure() produces
|
||||
// for a generic (non-SafeOutboundFetchError, non-FetchTimeoutError) transport
|
||||
// failure: message copied from the original error, cause = the original error.
|
||||
const wrapped = new SafeOutboundFetchError(nativeError.message, {
|
||||
code: "NETWORK_ERROR",
|
||||
url: "https://api.mistral.ai/v1/models",
|
||||
method: "GET",
|
||||
attempts: 1,
|
||||
isRetryable: true,
|
||||
cause: nativeError,
|
||||
});
|
||||
|
||||
const result = toValidationErrorResult(wrapped);
|
||||
|
||||
assert.notEqual(
|
||||
result.error,
|
||||
"fetch failed",
|
||||
"expected behavior: a concrete transport diagnosis was computed two layers down (error.cause.proxyFetchDetail), so the response must not collapse to the bare, non-actionable 'fetch failed' string"
|
||||
);
|
||||
assert.match(
|
||||
result.error || "",
|
||||
/ENOTFOUND|UND_ERR_SOCKET/,
|
||||
"expected behavior: the underlying DNS/socket error code should reach the dashboard so the operator can actually diagnose the failure"
|
||||
);
|
||||
});
|
||||
60
tests/unit/repro-14021-devtools-discoverability.test.ts
Normal file
60
tests/unit/repro-14021-devtools-discoverability.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Repro for issue #14021: Playground/Translator/Search Tools (the "Dev Tools" sidebar
|
||||
// group) are gated behind Debug Mode, but nothing in the UI says so, and Settings →
|
||||
// Sidebar applies the exact same debug filter — so with debug off there is no toggle for
|
||||
// Playground at all and no explanation. This test exercises the SAME filter predicate
|
||||
// both Sidebar.tsx (:277) and SidebarTab.tsx (:470) apply to `SIDEBAR_SECTIONS`, using the
|
||||
// real section/item config, and proves that with debugMode=false the "playground" item is
|
||||
// completely absent from what either surface would render — matching the issue's
|
||||
// acceptance criterion ("with debug off, Settings -> Sidebar either lists Playground or
|
||||
// explains why it cannot be toggled").
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
|
||||
|
||||
function visibleItemIdsWithDebug(showDebug: boolean): string[] {
|
||||
// This is exactly the predicate used in:
|
||||
// src/shared/components/Sidebar.tsx:277
|
||||
// src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx:470
|
||||
const visibleSections = sidebarVisibility.SIDEBAR_SECTIONS.filter(
|
||||
(section) => section.visibility !== "debug" || showDebug
|
||||
);
|
||||
const ids: string[] = [];
|
||||
for (const section of visibleSections) {
|
||||
for (const item of sidebarVisibility.getSectionItems(section)) {
|
||||
ids.push(item.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
test("issue #14021: devtools section is not debug-gated in config", () => {
|
||||
const devtools = sidebarVisibility.SIDEBAR_SECTIONS.find((s) => s.id === "devtools");
|
||||
assert.ok(devtools, "expected a 'devtools' sidebar section to exist");
|
||||
assert.notEqual(
|
||||
devtools!.visibility,
|
||||
"debug",
|
||||
"the devtools section must not be gated behind debugMode, per fix for #14021"
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #14021: with debugMode=false, Playground is discoverable in both the Sidebar and Settings->Sidebar", () => {
|
||||
const idsDebugOff = visibleItemIdsWithDebug(false);
|
||||
const idsDebugOn = visibleItemIdsWithDebug(true);
|
||||
|
||||
// Sanity: Playground DOES exist and IS reachable once debug is on (proves it's not a
|
||||
// typo/missing-id issue).
|
||||
assert.ok(
|
||||
idsDebugOn.includes("playground"),
|
||||
"expected 'playground' to be a real, resolvable sidebar item when debugMode=true"
|
||||
);
|
||||
|
||||
// The fix: playground is a normal hideable item
|
||||
// (HIDEABLE_SIDEBAR_ITEM_IDS includes "playground" — sidebarVisibility/types.ts:79) and
|
||||
// now appears with debug off too, satisfying the issue's acceptance criterion.
|
||||
assert.ok(
|
||||
idsDebugOff.includes("playground"),
|
||||
"FIX #14021: with debugMode=false, 'playground' item should be discoverable from " +
|
||||
"every sidebar-derived surface (main Sidebar AND Settings->Sidebar)."
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user