mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-24 08:32:13 +03:00
Compare commits
1 Commits
fix/14309-
...
fix/14070-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df1d8cefc9 |
1
changelog.d/fixes/14070-agentbridge-rootca-cert-trust.md
Normal file
1
changelog.d/fixes/14070-agentbridge-rootca-cert-trust.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(mitm): resolve AgentBridge cert-trust check against the active cert model instead of hard-coded server.crt (#14070)
|
||||
@@ -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}]`);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { getMitmStatus } from "@/mitm/manager";
|
||||
import { checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { summarizeDiagnostics } from "@/mitm/inspector/diagnostics";
|
||||
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
|
||||
@@ -46,7 +47,12 @@ export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const agentId = new URL(request.url).searchParams.get("agentId") ?? undefined;
|
||||
const status = await getMitmStatus(agentId);
|
||||
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
// #14070: resolve the file the active migration decision actually
|
||||
// installs (ca.crt under the root-CA model) instead of always
|
||||
// hard-coding the legacy server.crt path.
|
||||
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
||||
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
|
||||
const { certPath } = resolveActiveCertPath(certDir, rootCaEnabled);
|
||||
const certExists = fs.existsSync(certPath);
|
||||
const certTrusted = certExists ? await checkCertInstalled(certPath) : false;
|
||||
const port =
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
import { AgentBridgeServerActionSchema } from "@/shared/schemas/agentBridge";
|
||||
import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
|
||||
import { installCertResult, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { installCertResult, installCaCert, checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { generateCert } from "@/mitm/cert/generate";
|
||||
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import {
|
||||
isMitmSudoPasswordRequired,
|
||||
@@ -104,8 +105,17 @@ export async function POST(request: Request): Promise<Response> {
|
||||
if (isMitmSudoPasswordRequired(sudoPassword)) {
|
||||
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
|
||||
}
|
||||
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
const result = await installCertResult(sudoPassword, certPath);
|
||||
// #14070: resolve + trust the file the active migration decision
|
||||
// actually installs (ca.crt via installCaCert() under the root-CA
|
||||
// model) instead of always hard-coding/trusting the legacy
|
||||
// server.crt — mirrors manager.ts's own branch (startMitmInternal).
|
||||
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
||||
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
|
||||
const { certPath, mode } = resolveActiveCertPath(certDir, rootCaEnabled);
|
||||
const result =
|
||||
mode === "use-root-ca"
|
||||
? await installCaCert(sudoPassword, certPath)
|
||||
: await installCertResult(sudoPassword, certPath);
|
||||
if (result.installed) {
|
||||
const suppliedPassword =
|
||||
typeof raw.sudoPassword === "string"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState";
|
||||
import { getAllBypassPatterns } from "@/lib/db/agentBridgeBypass";
|
||||
import { getMappingsForAgent } from "@/lib/db/agentBridgeMappings";
|
||||
import { checkCertInstalled } from "@/mitm/cert/install";
|
||||
import { resolveActiveCertPath } from "@/mitm/cert/activeCert";
|
||||
import { resolveMitmDataDir } from "@/mitm/dataDir";
|
||||
import { ALL_TARGETS } from "@/mitm/targets/index";
|
||||
import path from "path";
|
||||
@@ -42,9 +43,13 @@ export async function GET(): Promise<Response> {
|
||||
);
|
||||
const mappings = Object.fromEntries(mappingsEntries);
|
||||
|
||||
// Compute REAL certTrusted (OS trust store check, not just file exists)
|
||||
// Compute REAL certTrusted (OS trust store check, not just file exists).
|
||||
// #14070: resolve the file the active migration decision actually
|
||||
// installs (ca.crt under the root-CA model) instead of always
|
||||
// hard-coding the legacy server.crt path.
|
||||
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
||||
const certPath = path.join(certDir, "server.crt");
|
||||
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
|
||||
const { certPath } = resolveActiveCertPath(certDir, rootCaEnabled);
|
||||
const certExists = fs.existsSync(certPath);
|
||||
const certTrusted = certExists ? await checkCertInstalled(certPath) : false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
src/mitm/cert/activeCert.ts
Normal file
34
src/mitm/cert/activeCert.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import path from "path";
|
||||
import { decideCertMigration, type CertMigrationDecision } from "./migration.ts";
|
||||
|
||||
// #14070: single source of truth for "which cert file is actually active
|
||||
// (installed + trusted) for this run". `decideCertMigration()` only answers
|
||||
// which MODEL is active; every caller that needs the concrete file path used
|
||||
// to independently re-derive it, and three of the four call sites (the
|
||||
// AgentBridge `state`/`diagnose`/`trust-cert` routes) hard-coded the legacy
|
||||
// `server.crt` path regardless of the decision — so once a install adopted
|
||||
// the root-CA model (`ca.crt`), those routes kept checking/trusting the
|
||||
// wrong file forever. `startMitmInternal()` (`../manager.ts`) is the only
|
||||
// place that got this right; this helper factors that logic out so every
|
||||
// caller (including `manager.ts` itself) resolves the same path the same
|
||||
// way.
|
||||
|
||||
/** The cert file actually active for this run, plus which model produced it. */
|
||||
export interface ActiveCertInfo {
|
||||
certPath: string;
|
||||
mode: CertMigrationDecision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cert file the active migration decision installs/trusts for
|
||||
* `certDir`. Mirrors `rootCa.ts`'s `ca.crt` filename under `"use-root-ca"`
|
||||
* and `generate.ts`'s `server.crt` filename under `"use-legacy-leaf"` — pure
|
||||
* path arithmetic, no filesystem I/O beyond what `decideCertMigration()`
|
||||
* itself performs (existence checks only).
|
||||
*/
|
||||
export function resolveActiveCertPath(certDir: string, rootCaEnabled: boolean): ActiveCertInfo {
|
||||
const mode = decideCertMigration(certDir, rootCaEnabled);
|
||||
const certPath =
|
||||
mode === "use-root-ca" ? path.join(certDir, "ca.crt") : path.join(certDir, "server.crt");
|
||||
return { certPath, mode };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { provisionDnsEntries } from "./dns/provision.ts";
|
||||
import { generateCert } from "./cert/generate.ts";
|
||||
import { installCertResult, installCaCert } from "./cert/install.ts";
|
||||
import { loadOrCreateMitmCa, resolveMitmCertDir } from "./cert/rootCa.ts";
|
||||
import { decideCertMigration } from "./cert/migration.ts";
|
||||
import { resolveActiveCertPath } from "./cert/activeCert.ts";
|
||||
import { ALL_TARGETS } from "./targets/index.ts";
|
||||
import { detectAgent } from "./detection/index.ts";
|
||||
import type { AgentId, DetectionResult, MitmTarget } from "./types.ts";
|
||||
@@ -419,9 +419,13 @@ export async function getMitmStatus(agentId?: string): Promise<{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Check cert
|
||||
// Check cert. #14070: resolve the file the active migration decision
|
||||
// actually installs (ca.crt under the root-CA model), not always the
|
||||
// legacy server.crt — otherwise a root-CA install with no leaf ever
|
||||
// generated would wrongly report certExists:false.
|
||||
const certDir = path.join(resolveMitmDataDir(), "mitm");
|
||||
const certExists = fs.existsSync(path.join(certDir, "server.crt"));
|
||||
const rootCaEnabledForStatus = process.env.MITM_ROOT_CA_ENABLED === "true";
|
||||
const certExists = fs.existsSync(resolveActiveCertPath(certDir, rootCaEnabledForStatus).certPath);
|
||||
|
||||
return {
|
||||
running,
|
||||
@@ -516,7 +520,7 @@ async function startMitmInternal(
|
||||
// `tproxy/dynamicCert.ts`).
|
||||
const certDir = resolveMitmCertDir();
|
||||
const rootCaEnabled = process.env.MITM_ROOT_CA_ENABLED === "true";
|
||||
const migrationDecision = decideCertMigration(certDir, rootCaEnabled);
|
||||
const { mode: migrationDecision } = resolveActiveCertPath(certDir, rootCaEnabled);
|
||||
let certPath: string;
|
||||
if (migrationDecision === "use-legacy-leaf") {
|
||||
certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
|
||||
|
||||
129
tests/unit/agent-bridge-cert-trust-mismatch.test.ts
Normal file
129
tests/unit/agent-bridge-cert-trust-mismatch.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Regression test for issue #14070 — "[Windows] AgentBridge reports
|
||||
// certificate untrusted and DNS off despite manual configuration".
|
||||
//
|
||||
// Root cause: the AgentBridge dashboard's cert-trust check
|
||||
// (/api/tools/agent-bridge/state, /diagnose, and the "trust-cert" manual
|
||||
// action) always read the LEGACY leaf server.crt from disk and computed its
|
||||
// OS-trust-store fingerprint from that file — regardless of which cert model
|
||||
// (cert/migration.ts::decideCertMigration) is actually active for this run.
|
||||
// startMitmInternal() (src/mitm/manager.ts) picks the correct model per-run
|
||||
// and, under "use-root-ca", installs/trusts ca.crt instead — a DIFFERENT
|
||||
// keypair with a DIFFERENT fingerprint.
|
||||
//
|
||||
// Fix: src/mitm/cert/activeCert.ts::resolveActiveCertPath() is the single
|
||||
// source of truth for "which cert file is active for this run", reused by
|
||||
// manager.ts and by all three AgentBridge route handlers.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { decideCertMigration } from "../../src/mitm/cert/migration.ts";
|
||||
import { loadOrCreateMitmCa } from "../../src/mitm/cert/rootCa.ts";
|
||||
import { certutilThumbprint } from "../../src/mitm/cert/install.ts";
|
||||
import { generateCert } from "../../src/mitm/cert/generate.ts";
|
||||
import { resolveActiveCertPath } from "../../src/mitm/cert/activeCert.ts";
|
||||
|
||||
function tmpDataDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentbridge-"));
|
||||
}
|
||||
|
||||
test("AgentBridge cert-trust check now targets the cert file the active migration decision actually installs (root-CA model)", async () => {
|
||||
const dataDir = tmpDataDir();
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
const certDir = path.join(dataDir, "mitm");
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
|
||||
// Recreate the reporter's on-disk state: a legacy leaf from an earlier
|
||||
// run, PLUS a persisted CA pair — the combination that activates the
|
||||
// root-CA model this run (decideCertMigration()).
|
||||
await generateCert();
|
||||
const legacyLeafPath = path.join(certDir, "server.crt");
|
||||
assert.equal(fs.existsSync(legacyLeafPath), true, "server.crt must exist for this scenario");
|
||||
|
||||
const ca = await loadOrCreateMitmCa(certDir);
|
||||
assert.equal(fs.existsSync(ca.certPath), true, "ca.crt must exist for this scenario");
|
||||
|
||||
const migrationDecision = decideCertMigration(certDir, false);
|
||||
assert.equal(
|
||||
migrationDecision,
|
||||
"use-root-ca",
|
||||
"with both a legacy leaf and a CA pair on disk, manager.ts activates the root-CA model this run"
|
||||
);
|
||||
const actuallyInstalledCertPath = ca.certPath;
|
||||
|
||||
// This is what the dashboard's cert-trust check now resolves to.
|
||||
const { certPath: dashboardCheckedCertPath, mode } = resolveActiveCertPath(certDir, false);
|
||||
|
||||
assert.equal(mode, "use-root-ca");
|
||||
assert.equal(
|
||||
dashboardCheckedCertPath,
|
||||
actuallyInstalledCertPath,
|
||||
"FIX: the dashboard's cert-trust check must target the same file (ca.crt) manager.ts actually installed/trusted for this run's active cert model"
|
||||
);
|
||||
|
||||
const dashboardCheckedThumbprint = certutilThumbprint(dashboardCheckedCertPath);
|
||||
const actuallyTrustedThumbprint = certutilThumbprint(actuallyInstalledCertPath);
|
||||
assert.equal(
|
||||
dashboardCheckedThumbprint,
|
||||
actuallyTrustedThumbprint,
|
||||
"FIX: the fingerprint the dashboard looks up in the OS store must be the fingerprint of the cert that was actually installed there"
|
||||
);
|
||||
} finally {
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("AgentBridge cert-trust check still targets server.crt for a legacy-only install (no CA pair, no behavior change)", async () => {
|
||||
const dataDir = tmpDataDir();
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
const certDir = path.join(dataDir, "mitm");
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
|
||||
await generateCert();
|
||||
const legacyLeafPath = path.join(certDir, "server.crt");
|
||||
assert.equal(fs.existsSync(legacyLeafPath), true, "server.crt must exist for this scenario");
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(certDir, "ca.crt")),
|
||||
false,
|
||||
"no CA pair must exist for this scenario"
|
||||
);
|
||||
|
||||
const { certPath, mode } = resolveActiveCertPath(certDir, false);
|
||||
|
||||
assert.equal(mode, "use-legacy-leaf");
|
||||
assert.equal(
|
||||
certPath,
|
||||
legacyLeafPath,
|
||||
"a pre-existing legacy-only install must keep resolving to server.crt — no silent upgrade"
|
||||
);
|
||||
} finally {
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveActiveCertPath: fresh install with the flag on resolves to ca.crt (matches decideCertMigration)", () => {
|
||||
const dataDir = tmpDataDir();
|
||||
try {
|
||||
const certDir = path.join(dataDir, "mitm");
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
|
||||
const { certPath, mode } = resolveActiveCertPath(certDir, true);
|
||||
|
||||
assert.equal(mode, "use-root-ca");
|
||||
assert.equal(certPath, path.join(certDir, "ca.crt"));
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user