fix(v3.8.24): plugins menu + proxy IP-family selector + CodeQL/test cleanup (#3800)

Surfaces the Plugins page (marketplace, #3656) in the sidebar; adds the proxy IP-family selector (auto/ipv4/ipv6) completing #3777's UI; clears the remaining CodeQL URL-substring alerts; covers #3799 with tests and fixes the costs-section count.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-13 19:30:31 -03:00
committed by GitHub
parent c8dee8c5f7
commit 24c785a3ba
10 changed files with 208 additions and 9 deletions

View File

@@ -13,15 +13,18 @@
- **feat(flags): expose the emergency budget fallback in the Feature Flags page** — `OMNIROUTE_EMERGENCY_FALLBACK` is now a runtime boolean (enabled by default, applied without restart) resolved through the feature-flag stack, so a DB override can toggle it while still honoring the raw env fallback. Follow-up to [#3741](https://github.com/diegosouzapw/OmniRoute/pull/3741) by @zoispag. ([#3752](https://github.com/diegosouzapw/OmniRoute/pull/3752) — thanks @rdself)
- **feat(reasoning): preserve `xhigh` reasoning effort by default** — `xhigh` now passes through unless a model explicitly sets `supportsXHighEffort: false`, with the existing `max` normalization kept separate. ([#3756](https://github.com/diegosouzapw/OmniRoute/pull/3756) — thanks @rdself)
- **feat(codex): inject OmniRoute memory into Codex Responses WebSocket requests** — retrieved memory is injected into the Responses WebSocket prepare request via the `instructions` field, with the retrieval query derived from the latest user input (skipping tool/reasoning payloads) and duplicate-safe injection. ([#3749](https://github.com/diegosouzapw/OmniRoute/pull/3749) — thanks @kkkayye)
- **feat(dashboard): free provider rankings page** — a new dashboard page (with sidebar entry) that ranks free providers (no-auth / OAuth / API-key) by their models' Arena ELO / intelligence scores, joining the provider registry with the `model_intelligence` table via fuzzy model-name matching. Pure computation over existing data — no external calls. ([#3799](https://github.com/diegosouzapw/OmniRoute/pull/3799) — thanks @pizzav-xyz)
### 🔒 Security
- **security(proxy): IPv6-only egress enforcement + closing IP-leak paths (L1/L2/L3)** — de-brackets IPv6 literals at the SOCKS host and the proxy-health tcpCheck (so `socks5://[2001:db8::1]` and any v6-literal proxy connect instead of dying with `ENOTFOUND`), adds a per-proxy `family` policy (`auto`/`ipv4`/`ipv6`), and enforces it end-to-end across SOCKS5/HTTP/HTTPS × global/provider/key × literal/hostname. 16 commits, TDD+BDD, 73 tests. ([#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777))
- **security(marketplace): harden the custom-URL SSRF guard** against three bypasses found by automated security review — IPv6/AAAA records (only `dns.resolve4` was checked, so a private AAAA record or an IPv6 literal slipped through), redirect-following (a public URL could 30x to an internal one), and DNS-rebinding TOCTOU. The guard now resolves A+AAAA via the canonical `isPrivateHost`, routes the fetch through `safeOutboundFetch` (public-only, blocks redirects to private hosts), and re-validates on fetch. Reachable only with management auth + a custom `pluginMarketplaceUrl`. Follow-up to [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656). ([#3774](https://github.com/diegosouzapw/OmniRoute/pull/3774))
- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778))
- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778)) The remaining `js/incomplete-url-substring-sanitization` instances in the api-key proxy-context test were also cleared by asserting on the parsed URL host/port instead of a substring `includes`. (thanks @diegosouzapw)
### 🐛 Fixed
- **fix(dashboard): surface the Plugins page (plugin manager + marketplace) in the sidebar** — the plugins page (`/dashboard/plugins`), which hosts the custom plugin marketplace shipped in [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656), had no menu entry and was reachable only by typing the URL. It now appears under **Agentic Features**. (thanks @diegosouzapw)
- **fix(proxy): add the IP-family selector (auto / IPv4-only / IPv6-only) to the proxy form** — the per-proxy `family` egress policy from [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777) was backend-only (the dashboard had no control, so every proxy stayed on `auto`). The proxy registry form now exposes the selector and the create/update schema accepts it, so IPv6-only egress can be enabled from the UI. (thanks @diegosouzapw)
- **fix(combo): deep audit of the combo + quota-shared routing system** — repairs 5 dead/broken rules (streaming-USD cost recording, quota-pool usage provider resolution, provider-diversity wiring, `maxComboDepth` threading, and scoring clamp/NaN-safety incl. `connectionDensity`) and revives the dead `tierAffinity`/`specificityMatch` scoring factors — root cause was a `require()` that throws under ESM, so both factors silently collapsed to `0.5`; now a static import. Validates every auto-router strategy (cost / latency / sla-aware / lkgp / `selectWithStrategy` + aliases) and the predictive-TTFT decision, adds E2E coverage (3-hop priority failover, per-target timeout failover, real `strategy:auto` dispatch), and introduces opt-in complexity-aware routing (2026) layered over the existing specificity detector. Per-target credential+proxy isolation verified clean (`AsyncLocalStorage`). 4 TDD waves, 10 new/updated test files. ([#3779](https://github.com/diegosouzapw/OmniRoute/pull/3779) — thanks @diegosouzapw)
- fix(anthropic): normalize sampling params under extended thinking — Claude models with extended thinking (e.g. Opus 4.8 via the Claude Code provider) returned **HTTP 400** when a request carried non-default `temperature`/`top_p` (`temperature may only be set to 1 …`, `top_p must be ≥ 0.95 or unset …`). Tools like VS Code Copilot's "Ollama" BYOK send `temperature: 0.7` + `top_p: 0.9`, so every thinking-enabled Claude request failed; the proxy now drops/normalizes these params at the chokepoint so the request succeeds. ([#3780](https://github.com/diegosouzapw/OmniRoute/pull/3780) — thanks @zhiru)
- fix(sse): pass Claude passthrough `thinking` blocks through unchanged — the Anthropic-native Claude OAuth passthrough rewrote every assistant `thinking` block to `redacted_thinking`, which the Messages API rejects (submitted thinking blocks are validated against the original response), so every multi-turn request with extended thinking failed with `400 … thinking blocks … cannot be modified` (very visible on long Claude Code tool-loops). The blocks are now passed through verbatim; the signature is validated server-side and stays valid on replay (including across an OAuth token switch), so the redaction was unnecessary. ([#3775](https://github.com/diegosouzapw/OmniRoute/pull/3775) — thanks @havockdev)

View File

@@ -15,6 +15,7 @@ type ProxyItem = {
region?: string | null;
notes?: string | null;
status?: string;
family?: string;
};
type UsageInfo = {
@@ -66,6 +67,7 @@ const EMPTY_FORM = {
region: "",
notes: "",
status: "active",
family: "auto",
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
@@ -280,6 +282,7 @@ export default function ProxyRegistryManager() {
region: item.region || "",
notes: item.notes || "",
status: item.status || "active",
family: item.family || "auto",
});
setModalOpen(true);
};
@@ -366,6 +369,7 @@ export default function ProxyRegistryManager() {
region: (form.region || "").trim() || null,
notes: (form.notes || "").trim() || null,
status: form.status,
family: form.family || "auto",
};
if (!editingId || normalizedUsername.length > 0) {
payload.username = normalizedUsername;
@@ -766,6 +770,19 @@ export default function ProxyRegistryManager() {
<option value="socks5">SOCKS5</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("labelFamily")}</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.family}
onChange={(e) => setForm((prev) => ({ ...prev, family: e.target.value }))}
>
<option value="auto">{t("familyAuto")}</option>
<option value="ipv4">{t("familyIpv4")}</option>
<option value="ipv6">{t("familyIpv6")}</option>
</select>
<p className="text-[11px] text-text-muted mt-1">{t("familyHint")}</p>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("labelHost")}</label>
<input

View File

@@ -960,6 +960,7 @@
"aiFeaturesSection": "AI Features",
"mcp": "MCP Server",
"a2a": "A2A Server",
"plugins": "Plugins",
"apiEndpoints": "API Endpoints",
"batchFiles": "Files",
"analyticsEvals": "Evals",
@@ -1055,6 +1056,7 @@
"agentSkillsSubtitle": "A2A skill registry",
"mcpSubtitle": "MCP server controls",
"a2aSubtitle": "A2A protocol server",
"pluginsSubtitle": "Plugin marketplace & installs",
"mediaSubtitle": "Cached media files",
"batchFilesSubtitle": "Batch input/output files",
"settingsSubtitle": "All settings",
@@ -7595,6 +7597,11 @@
"modalEditTitle": "Edit Proxy",
"labelName": "Name",
"labelType": "Type",
"labelFamily": "IP family",
"familyAuto": "Auto (dual-stack)",
"familyIpv4": "IPv4 only",
"familyIpv6": "IPv6 only",
"familyHint": "Egress address family. Auto keeps dual-stack; IPv4/IPv6 pin this proxy to that family (prevents a v4 leak under an IPv6-only proxy).",
"labelHost": "Host",
"labelPort": "Port",
"labelUsername": "Username",

View File

@@ -64,6 +64,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"agent-skills",
"mcp",
"a2a",
"plugins",
// Gamification
"leaderboard",
"profile",
@@ -587,6 +588,13 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [
subtitleKey: "a2aSubtitle",
icon: "device_hub",
},
{
id: "plugins",
href: "/dashboard/plugins",
i18nKey: "plugins",
subtitleKey: "pluginsSubtitle",
icon: "extension",
},
];
const GAMIFICATION_GROUP: SidebarItemGroup = {

View File

@@ -1576,6 +1576,9 @@ const proxyRegistryFieldsSchema = z
notes: z.string().trim().max(1000).nullable().optional(),
status: z.enum(["active", "inactive"]).optional().default("active"),
source: z.enum(["manual", "oneproxy", "dashboard-custom", "vercel-relay"]).optional(),
// Address-family egress policy (#3777): "auto" keeps the prior dual-stack behavior;
// "ipv4"/"ipv6" pin the connection to that family (no v4 leak under an IPv6-only proxy).
family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"),
})
.strict();

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Regression coverage for the free-provider-rankings feature (#3799), which shipped
// without tests. The matching helpers are pure; computeFreeProviderRankings needs a DB
// (model_intelligence) so we point DATA_DIR at a throwaway SQLite file.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-rankings-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const core = await import("../../src/lib/db/core.ts");
const { stripVersionSuffix, findMatchingIntelligence, computeFreeProviderRankings } =
await import("../../src/lib/freeProviderRankings.ts");
test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
});
test("stripVersionSuffix strips only the trailing version numbers", () => {
assert.equal(stripVersionSuffix("kimi-k2.6"), "kimi-k2");
assert.equal(stripVersionSuffix("gpt-5.5"), "gpt-5");
assert.equal(stripVersionSuffix("claude-opus-4.8.1"), "claude-opus-4");
assert.equal(stripVersionSuffix("gpt-5"), "gpt-5"); // no version suffix → unchanged
assert.equal(stripVersionSuffix("llama-3-70b"), "llama-3-70b"); // digits not a trailing .N
});
test("findMatchingIntelligence prefers exact, then version-stripped, then prefix", () => {
const intelMap = new Map([
["gpt-5", [{ score: 90, eloRaw: 1400, confidence: "high", category: "default" }]],
["kimi-k2", [{ score: 80, eloRaw: 1300, confidence: "mid", category: "default" }]],
]);
// Strategy 1: exact match.
assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 90);
// Strategy 2: "kimi-k2.6" → stripped "kimi-k2".
assert.equal(findMatchingIntelligence("kimi-k2.6", intelMap)?.score, 80);
// Strategy 3: prefix — "gpt-5-turbo" starts with "gpt-5-".
assert.equal(findMatchingIntelligence("gpt-5-turbo", intelMap)?.score, 90);
// No match anywhere.
assert.equal(findMatchingIntelligence("mistral-large", intelMap), null);
});
test("findMatchingIntelligence picks the highest-scoring entry on a tie key", () => {
const intelMap = new Map([
[
"gpt-5",
[
{ score: 70, eloRaw: 1200, confidence: "low", category: "default" },
{ score: 95, eloRaw: 1450, confidence: "high", category: "default" },
],
],
]);
assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 95);
});
test("computeFreeProviderRankings returns an array and respects the limit", () => {
const all = computeFreeProviderRankings();
assert.ok(Array.isArray(all), "expected an array of rankings");
const limited = computeFreeProviderRankings(undefined, 2);
assert.ok(limited.length <= 2, `expected at most 2 rankings, got ${limited.length}`);
for (const ranking of limited) {
assert.equal(typeof ranking.id, "string");
assert.ok(["noauth", "oauth", "apikey"].includes(ranking.category));
}
});

View File

@@ -12,7 +12,12 @@ describe("API-key usage egresses through proxy context", () => {
// Deterministic, no network dependency: this is the core mechanism the L3 fix uses
// when wrapping getUsageForProvider in runWithProxyContext(apiKeyProxy?.proxy ?? null).
const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080 });
assert.ok(url && url.includes("p.example.com:8080"), `expected proxy url, got ${url}`);
assert.ok(url, `expected proxy url, got ${url}`);
// Parse and compare host/port exactly (substring matching on a URL is unsafe — CodeQL
// js/incomplete-url-substring-sanitization — and a weaker assertion than equality).
const parsed = new URL(url);
assert.equal(parsed.hostname, "p.example.com");
assert.equal(parsed.port, "8080");
});
it("a null proxy config (no connection proxy) resolves to no proxy", () => {
@@ -28,7 +33,8 @@ describe("API-key usage egresses through proxy context", () => {
await runWithProxyContext({ type: "http", host: "p.example.com", port: 8080 }, async () => {
const r = resolveProxyForRequest("https://api.example.com");
assert.equal(r.source, "context");
assert.ok(r.proxyUrl && r.proxyUrl.includes("p.example.com"));
assert.ok(r.proxyUrl, "expected a proxy url from context");
assert.equal(new URL(r.proxyUrl).hostname, "p.example.com");
});
} catch (err) {
// Expected when the proxy host is unreachable; the mechanism is still proven by the

View File

@@ -14,6 +14,7 @@ const proxiesDb = await import("../../src/lib/db/proxies.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts");
const { createProxyRegistrySchema } = await import("../../src/shared/validation/schemas.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
@@ -479,3 +480,59 @@ test("createProxy persists type:vercel and source:vercel-relay to DB (schema gap
assert.equal(created?.type, "vercel");
assert.equal(created?.source, "vercel-relay");
});
test("proxy registry schema accepts the IP family policy and defaults it to auto (#3777)", () => {
// The dashboard proxy form (ProxyRegistryManager) now sends `family`. The schema is
// .strict(), so an undeclared field would 400 the whole request — this guards the wiring.
const explicit = createProxyRegistrySchema.parse({
name: "v6-only proxy",
type: "socks5",
host: "proxy.example.com",
port: 1080,
family: "ipv6",
});
assert.equal(explicit.family, "ipv6");
const defaulted = createProxyRegistrySchema.parse({
name: "default family proxy",
type: "http",
host: "proxy.example.com",
port: 8080,
});
assert.equal(defaulted.family, "auto");
assert.throws(() =>
createProxyRegistrySchema.parse({
name: "bad family",
type: "http",
host: "proxy.example.com",
port: 8080,
family: "ipv7",
})
);
});
test("createProxy persists the IP family and reads it back (#3777)", async () => {
await resetStorage();
const created = await proxiesDb.createProxy({
name: "Family RoundTrip",
type: "socks5",
host: "v6.example.com",
port: 1080,
family: "ipv6",
});
assert.equal(created?.family, "ipv6");
const fetched = await proxiesDb.getProxyById(created.id, { includeSecrets: false });
assert.equal(fetched?.family, "ipv6");
// Omitting family defaults to "auto" (prior dual-stack behavior, no regression).
const legacy = await proxiesDb.createProxy({
name: "Family Default",
type: "http",
host: "dual.example.com",
port: 8080,
});
assert.equal(legacy?.family, "auto");
});

View File

@@ -12,15 +12,21 @@ test("costs section exists in SIDEBAR_SECTIONS", () => {
assert.ok(section, "costs section must exist");
});
test("costs section has exactly 4 items in the correct order", () => {
test("costs section has exactly 5 items in the correct order", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const items = sidebarVisibility.getSectionItems(section);
assert.equal(items.length, 4, "costs section must have 4 items");
assert.equal(items.length, 5, "costs section must have 5 items");
const itemIds = items.map((i) => i.id);
assert.deepEqual(itemIds, ["costs", "costs-pricing", "costs-budget", "costs-free-tiers"]);
assert.deepEqual(itemIds, [
"costs",
"costs-pricing",
"costs-budget",
"costs-free-tiers",
"free-provider-rankings",
]);
});
test("costs section items have correct hrefs", () => {
@@ -35,6 +41,7 @@ test("costs section items have correct hrefs", () => {
{ id: "costs-pricing", href: "/dashboard/costs/pricing" },
{ id: "costs-budget", href: "/dashboard/costs/budget" },
{ id: "costs-free-tiers", href: "/dashboard/free-tiers" },
{ id: "free-provider-rankings", href: "/dashboard/free-provider-rankings" },
]);
});
@@ -58,7 +65,7 @@ test("costs item was removed from analytics section", () => {
assert.equal(
analyticsItemIds.includes("costs" as sidebarVisibility.HideableSidebarItemId),
false,
"costs item must not be in analytics section",
"costs item must not be in analytics section"
);
});
@@ -74,11 +81,11 @@ test("costs section is positioned between analytics and monitoring", () => {
assert.ok(
analyticsIdx < costsIdx,
`analytics (${analyticsIdx}) must come before costs (${costsIdx})`,
`analytics (${analyticsIdx}) must come before costs (${costsIdx})`
);
assert.ok(
costsIdx < monitoringIdx,
`costs (${costsIdx}) must come before monitoring (${monitoringIdx})`,
`costs (${costsIdx}) must come before monitoring (${monitoringIdx})`
);
});

View File

@@ -113,6 +113,22 @@ test("help sidebar exposes changelog after docs and issues", () => {
assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("changelog"), true);
});
test("plugins (marketplace) has a discoverable sidebar entry (#3656 follow-up)", async () => {
const items = sectionItems("agentic-features");
const plugins = items.find((item) => item.id === "plugins");
assert.ok(plugins, "expected a plugins item in the agentic-features section");
assert.equal(plugins.href, "/dashboard/plugins");
assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("plugins"), true);
// It must be a real page (plugin manager + marketplace tab), not a legacy redirect stub.
const pluginsPage = await readFile(
join(repoRoot, "src/app/(dashboard)/dashboard/plugins/page.tsx"),
"utf8"
);
assert.doesNotMatch(pluginsPage, /^\s*redirect\(/m);
assert.match(pluginsPage, /marketplace/i);
});
test("legacy dashboard routes redirect to their consolidated surfaces", async () => {
const autoComboPage = await readFile(
join(repoRoot, "src/app/(dashboard)/dashboard/auto-combo/page.tsx"),