mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
Integrated into release/v3.8.41 — Proxy Pool dropdown by-id for account proxies (#5217 Gap 1).
This commit is contained in:
committed by
GitHub
parent
734061f736
commit
24f178f3fc
@@ -10,10 +10,10 @@ _In development — bullets added per PR; finalized at release._
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **thinking (claude):** let reasoning_content-native clients (e.g. Cursor) opt out of the `</think>` close-marker via the request header `x-omniroute-thinking-marker: off` so it no longer leaks an orphan `</think>` into visible `content` (RC-C of #5312, shared with #5245). The suppression machinery already existed (UA allowlist, #5348); this adds explicit per-request opt-in (also `on`/`keep`). Header absent ⇒ byte-identical default (Claude Code/#4633 clients still get the marker). Regression guard: `tests/unit/think-close-marker-suppress-5245.test.ts`. ([#5312](https://github.com/diegosouzapw/OmniRoute/issues/5312), [#5245](https://github.com/diegosouzapw/OmniRoute/issues/5245))
|
||||
- **cors:** browser/Electron clients (e.g. Wayland AI) can now use OmniRoute as an OpenAI-compatible provider out-of-the-box. The token-authenticated API surface (`/v1/*`, `/v1beta/*`) now returns a permissive `Access-Control-Allow-Origin` (echoes the request `Origin`, `*` when absent) by default — matching 9router and the OpenAI-compatible ecosystem — so a renderer `fetch` can read the response instead of failing CORS-blocked as "site not found" / empty catalog (while `curl`, which sends no preflight, worked). This is **safe**: those routes auth via `Authorization`/`x-api-key` headers browsers never auto-attach (no credentialed-session/CSRF exposure), and `Access-Control-Allow-Credentials` is never paired with the echo/wildcard. Cookie-authed **MANAGEMENT/dashboard routes stay exactly fail-closed**; `CORS_ALLOW_ALL`/`CORS_ALLOWED_ORIGINS` still take precedence. Regression guards: `tests/unit/cors/origins.test.ts`, `tests/unit/authz/pipeline.test.ts`. (Bug 2 of [#5242](https://github.com/diegosouzapw/OmniRoute/issues/5242) — thanks @jonlwheat2-gif)
|
||||
- **grok-web:** forward the Cloudflare clearance cookies and stop mislabeling IP-reputation blocks as a bad cookie. "Check cookie" returned `Invalid SSO cookie` even with a valid, complete browser session — but the cookie parser was never the problem (it robustly extracts `sso`/`sso-rw` from a full DevTools header). Two real gaps fixed: **(1)** `buildGrokCookieHeader` now forwards `cf_clearance` and `__cf_bm` when pasted (it dropped them before; AIClient2API forwards them too) — strictly additive, a bare `sso` blob still yields exactly `sso=…`; **(2)** when the user supplied a `cf_clearance`, a 401 / invalid-credentials-403 from grok.com is now surfaced as an IP-reputation/anti-bot block (cf_clearance is IP+TLS+UA-pinned and can't be replayed from a different machine) instead of the misleading "Invalid SSO cookie — re-paste". A bare cookie with no clearance still gets the re-paste hint. Regression guards in `web-cookie-auth.test.ts` + `provider-validation-specialty.test.ts`. ([#5350](https://github.com/diegosouzapw/OmniRoute/issues/5350) — thanks @SeaXen)
|
||||
- **cli (serve):** opt-in native **HTTPS/TLS** for `omniroute serve` — so strict-CSP Electron apps and browsers can reach OmniRoute over `https://` instead of plain `http://localhost`. Provide `--tls-cert <path> --tls-key <path>` (or `OMNIROUTE_TLS_CERT`/`OMNIROUTE_TLS_KEY`) and the standalone server terminates TLS on the same listener (no extra port/proxy); WebSocket upgrade (live dashboard + `/v1` streaming) works over `wss://` unchanged since `https.Server extends http.Server`. With no TLS flags the HTTP path is byte-identical to before; only one of cert/key, or an unreadable path, logs a warning and stays HTTP (never half-enables, never crashes). Auto-generated self-signed certs for localhost are a follow-up; for now provide an explicit cert/key (or front OmniRoute with a TLS terminator). Regression guard: `tests/unit/tls-options.test.ts`. (Bug 1C of [#5242](https://github.com/diegosouzapw/OmniRoute/issues/5242) — thanks @jonlwheat2-gif)
|
||||
- **opencode (proxy pool):** the OpenCode Free per-account proxy modal now offers the global **Proxy Pool dropdown** (by-id reference) instead of forcing manual Host/Port/credentials on every account — Gap 1 of #5217. A **Saved / Custom** toggle: "Saved" picks a pre-saved proxy from `GET /api/settings/proxies` and stores `{fingerprint, proxyId}`, so updating that pool proxy applies to every account using it; "Custom" keeps the manual inputs (stored inline) as an escape hatch. Resolution happens server-side (`resolveAccountProxiesFromRegistry`) so the executor still receives a resolved proxy unchanged; existing inline entries keep working and an unknown/deleted `proxyId` degrades safely to direct. Regression guards: `tests/unit/noauth-proxy-resolution.test.ts`, `tests/unit/ui/noauth-account-card.test.tsx`. ([#5217](https://github.com/diegosouzapw/OmniRoute/issues/5217) Gap 1 — thanks @daniij)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **opencode/observability:** make OpenCode Free account/proxy rotation visible and fix two real defects surfaced alongside it. **(1)** the per-request rotation selection log (`dispatch via account … through proxy …`) was `debug` (hidden at default `APP_LOG_LEVEL=info`) — promoted to `info` so the shuffle/cooldown lifecycle is auditable (token stays masked). **(2)** `[ProxyEgress]` reported `proxy=direct` even when an account proxy was applied, because the egress logger ran outside the executor's nested proxy context — the effective applied proxy is now captured (via an applied-proxy sink threaded through the proxy AsyncLocalStorage) and reflected in the egress log. **(3)** `[callLogs] too many SQL variables` — `deleteCallLogRowsByIds` deleted up to 5000 ids in one `IN (…)`, exceeding SQLite's ~999 bound-param cap and aborting log trimming/retention; ids are now chunked (≤500 per statement). Regression guards: `tests/unit/call-log-trim-sql-vars-5217.test.ts`, `apply-executor-proxy-info-5217.test.ts`, extended `opencode-proxy-rotation-4954.test.ts`. The Proxy Pool dropdown (by-id) UI (Gap 1) is a follow-up requiring browser validation. ([#5217](https://github.com/diegosouzapw/OmniRoute/issues/5217) — thanks @daniij)
|
||||
- **chatgpt-web:** wire tool/function calling into the `chatgpt-web` provider. It was the only web-session executor that never read `body.tools` — both response builders hardcoded `finish_reason:"stop"` and emitted only content, so tool calls were silently dropped (the model answered in prose). It now uses the shared `webTools` prompt-emulation shim (a `<tool>`-contract system message + `<tool>{…}</tool>` response parsing) exactly like its 9 sibling executors (qwen-web, perplexity-web, …) — it was simply omitted from the #3259 rollout. Tool mode buffers and emits `tool_calls` + `finish_reason:"tool_calls"` (gated off the image-gen path); plain chat is unchanged. Regression guard: `tests/unit/chatgpt-web-tools-5240.test.ts`. ([#5240](https://github.com/diegosouzapw/OmniRoute/issues/5240) — thanks @Rougler)
|
||||
- **oauth/dashboard:** fix the persistent/false Antigravity "Token Expired" badge (continuation of #3679/#3850). Two causes: **(1)** new OAuth connections never set `tokenExpiresAt` (only `expiresAt`), so the dashboard badge — which prefers `tokenExpiresAt || expiresAt` — fell back to the original grant clock and could flash a false "Token Expired" until the first background refresh. Creation now mirrors `expiresAt` into `tokenExpiresAt` across all 5 OAuth create paths (a shared `buildOAuthConnectionCreatePayload`), consistent with every refresh path which already writes both. **(2)** when a refresh-capable connection has no usable refresh token, the health-check sweep silently skipped it, leaving `testStatus="active"` forever while the cosmetic badge showed expired; it now surfaces a terminal `testStatus="expired"` ("needs re-auth"), tightly gated so it never clobbers non-refresh providers or already-terminal/cooldown states. Regression guards: `tests/unit/oauth-connection-tokenexpiresat-5326.test.ts`, `tests/unit/token-health-no-refresh-token-expired-5326.test.ts`. ([#5326](https://github.com/diegosouzapw/OmniRoute/issues/5326))
|
||||
|
||||
@@ -26,9 +26,30 @@ interface Connection {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface InlineProxy {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// #5217 (Gap 1): an account proxy is now stored as EITHER a Proxy Pool reference
|
||||
// (`proxyId`, resolved server-side so a pool edit propagates to every account) OR
|
||||
// a one-off inline `proxy` (the "custom" escape hatch / legacy entries).
|
||||
interface AccountProxyConfig {
|
||||
fingerprint: string;
|
||||
proxy: { type: string; host: string; port: number; username?: string; password?: string } | null;
|
||||
proxy?: InlineProxy | null;
|
||||
proxyId?: string | null;
|
||||
}
|
||||
|
||||
interface SavedProxy {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const PROXY_TYPES = [
|
||||
@@ -41,8 +62,26 @@ function getAccountProxies(conn: Connection | undefined): AccountProxyConfig[] {
|
||||
return (conn?.providerSpecificData?.accountProxies as AccountProxyConfig[]) || [];
|
||||
}
|
||||
|
||||
function getProxyForFingerprint(proxies: AccountProxyConfig[], fp: string) {
|
||||
return proxies.find((p) => p.fingerprint === fp)?.proxy ?? null;
|
||||
function getEntryForFingerprint(proxies: AccountProxyConfig[], fp: string) {
|
||||
return proxies.find((p) => p.fingerprint === fp) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the proxy to DISPLAY for an account: a by-id reference is looked up in
|
||||
* the Proxy Pool list, an inline proxy is shown directly. Returns null (direct)
|
||||
* when there is no entry or the referenced pool proxy no longer exists.
|
||||
*/
|
||||
function getDisplayProxy(
|
||||
entry: AccountProxyConfig | null,
|
||||
savedProxies: SavedProxy[]
|
||||
): InlineProxy | null {
|
||||
if (!entry) return null;
|
||||
if (entry.proxyId) {
|
||||
const found = savedProxies.find((p) => p.id === entry.proxyId);
|
||||
if (!found || !found.host) return null;
|
||||
return { type: found.type || "socks5", host: found.host, port: Number(found.port) || 0 };
|
||||
}
|
||||
return entry.proxy ?? null;
|
||||
}
|
||||
|
||||
export default function NoAuthAccountCard({
|
||||
@@ -60,6 +99,9 @@ export default function NoAuthAccountCard({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [proxyAccountId, setProxyAccountId] = useState<string | null>(null);
|
||||
const [proxyMode, setProxyMode] = useState<"saved" | "custom">("saved");
|
||||
const [savedProxies, setSavedProxies] = useState<SavedProxy[]>([]);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState("");
|
||||
const [proxyType, setProxyType] = useState("socks5");
|
||||
const [proxyHost, setProxyHost] = useState("");
|
||||
const [proxyPort, setProxyPort] = useState("1080");
|
||||
@@ -85,9 +127,22 @@ export default function NoAuthAccountCard({
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
const fetchSavedProxies = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSavedProxies(Array.isArray(data?.items) ? data.items : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch saved proxies:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConnections();
|
||||
}, [fetchConnections]);
|
||||
void fetchSavedProxies();
|
||||
}, [fetchConnections, fetchSavedProxies]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -162,19 +217,27 @@ export default function NoAuthAccountCard({
|
||||
};
|
||||
|
||||
const openProxyConfig = (accountId: string) => {
|
||||
const existing = getProxyForFingerprint(accountProxies, accountId);
|
||||
if (existing) {
|
||||
setProxyType(existing.type);
|
||||
setProxyHost(existing.host);
|
||||
setProxyPort(String(existing.port));
|
||||
setProxyUsername(existing.username || "");
|
||||
setProxyPassword(existing.password || "");
|
||||
const existing = getEntryForFingerprint(accountProxies, accountId);
|
||||
// Reset custom-form fields, then prefill from whichever shape was stored.
|
||||
setProxyType("socks5");
|
||||
setProxyHost("");
|
||||
setProxyPort("1080");
|
||||
setProxyUsername("");
|
||||
setProxyPassword("");
|
||||
setSelectedProxyId("");
|
||||
if (existing?.proxyId) {
|
||||
setProxyMode("saved");
|
||||
setSelectedProxyId(existing.proxyId);
|
||||
} else if (existing?.proxy?.host) {
|
||||
setProxyMode("custom");
|
||||
setProxyType(existing.proxy.type);
|
||||
setProxyHost(existing.proxy.host);
|
||||
setProxyPort(String(existing.proxy.port));
|
||||
setProxyUsername(existing.proxy.username || "");
|
||||
setProxyPassword(existing.proxy.password || "");
|
||||
} else {
|
||||
setProxyType("socks5");
|
||||
setProxyHost("");
|
||||
setProxyPort("1080");
|
||||
setProxyUsername("");
|
||||
setProxyPassword("");
|
||||
// New: default to the Proxy Pool dropdown when pool entries exist.
|
||||
setProxyMode(savedProxies.length > 0 ? "saved" : "custom");
|
||||
}
|
||||
setProxyAccountId(accountId);
|
||||
};
|
||||
@@ -183,21 +246,30 @@ export default function NoAuthAccountCard({
|
||||
if (!conn || !proxyAccountId) return;
|
||||
setSavingProxy(true);
|
||||
try {
|
||||
const trimmedHost = proxyHost.trim();
|
||||
const newProxy: AccountProxyConfig["proxy"] = trimmedHost
|
||||
? {
|
||||
type: proxyType,
|
||||
host: trimmedHost,
|
||||
port: Number(proxyPort) || 1080,
|
||||
...(proxyUsername.trim() ? { username: proxyUsername.trim() } : {}),
|
||||
...(proxyPassword.trim() ? { password: proxyPassword.trim() } : {}),
|
||||
}
|
||||
: null;
|
||||
const others = accountProxies.filter((p) => p.fingerprint !== proxyAccountId);
|
||||
let newEntry: AccountProxyConfig | null = null;
|
||||
if (proxyMode === "saved") {
|
||||
// Store a REFERENCE (by id); server resolves it to a live proxy record.
|
||||
newEntry = selectedProxyId
|
||||
? { fingerprint: proxyAccountId, proxyId: selectedProxyId }
|
||||
: null;
|
||||
} else {
|
||||
const trimmedHost = proxyHost.trim();
|
||||
newEntry = trimmedHost
|
||||
? {
|
||||
fingerprint: proxyAccountId,
|
||||
proxy: {
|
||||
type: proxyType,
|
||||
host: trimmedHost,
|
||||
port: Number(proxyPort) || 1080,
|
||||
...(proxyUsername.trim() ? { username: proxyUsername.trim() } : {}),
|
||||
...(proxyPassword.trim() ? { password: proxyPassword.trim() } : {}),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
const existing = accountProxies.filter((p) => p.fingerprint !== proxyAccountId);
|
||||
const updatedProxies = newProxy
|
||||
? [...existing, { fingerprint: proxyAccountId, proxy: newProxy }]
|
||||
: existing;
|
||||
const updatedProxies = newEntry ? [...others, newEntry] : others;
|
||||
|
||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
@@ -228,19 +300,12 @@ export default function NoAuthAccountCard({
|
||||
throw new Error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
}
|
||||
|
||||
const updatedProxies: AccountProxyConfig[] = allAccountIds.map((fp, i) => {
|
||||
const proxy = savedProxies[i % savedProxies.length];
|
||||
return {
|
||||
fingerprint: fp,
|
||||
proxy: {
|
||||
type: proxy.type || "socks5",
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
...(proxy.username ? { username: proxy.username } : {}),
|
||||
...(proxy.password ? { password: proxy.password } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
// #5217 (Gap 1): distribute stores by-id references too, so editing a pool
|
||||
// proxy later propagates to every account it was distributed to.
|
||||
const updatedProxies: AccountProxyConfig[] = allAccountIds.map((fp, i) => ({
|
||||
fingerprint: fp,
|
||||
proxyId: savedProxies[i % savedProxies.length].id,
|
||||
}));
|
||||
|
||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
@@ -305,7 +370,7 @@ export default function NoAuthAccountCard({
|
||||
className="grid max-h-72 grid-cols-1 gap-1.5 overflow-y-auto pr-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{allAccountIds.map((id, i) => {
|
||||
const proxy = getProxyForFingerprint(accountProxies, id);
|
||||
const proxy = getDisplayProxy(getEntryForFingerprint(accountProxies, id), savedProxies);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
@@ -360,47 +425,95 @@ export default function NoAuthAccountCard({
|
||||
Proxy for Account {allAccountIds.indexOf(proxyAccountId) + 1}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={proxyType}
|
||||
onChange={(e) => setProxyType(e.target.value)}
|
||||
className="flex-shrink-0 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
{/* #5217 (Gap 1): pick a pre-saved Proxy Pool entry by reference,
|
||||
or fall back to a one-off custom proxy. */}
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProxyMode("saved")}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
proxyMode === "saved"
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{PROXY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
Saved
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProxyMode("custom")}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
proxyMode === "custom"
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{proxyMode === "saved" ? (
|
||||
<select
|
||||
value={selectedProxyId}
|
||||
onChange={(e) => setSelectedProxyId(e.target.value)}
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
>
|
||||
<option value="">
|
||||
{savedProxies.length === 0
|
||||
? "No saved proxies — add one in Settings → Proxy"
|
||||
: "Direct (no proxy)"}
|
||||
</option>
|
||||
{savedProxies.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{(p.name || p.host) ?? p.id} ({p.type || "socks5"}://{p.host}:{p.port})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyHost}
|
||||
onChange={(e) => setProxyHost(e.target.value)}
|
||||
placeholder="Host"
|
||||
className="flex-1 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyPort}
|
||||
onChange={(e) => setProxyPort(e.target.value)}
|
||||
placeholder="Port"
|
||||
className="w-16 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyUsername}
|
||||
onChange={(e) => setProxyUsername(e.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={proxyPassword}
|
||||
onChange={(e) => setProxyPassword(e.target.value)}
|
||||
placeholder="Password (optional)"
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={proxyType}
|
||||
onChange={(e) => setProxyType(e.target.value)}
|
||||
className="flex-shrink-0 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
>
|
||||
{PROXY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyHost}
|
||||
onChange={(e) => setProxyHost(e.target.value)}
|
||||
placeholder="Host"
|
||||
className="flex-1 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyPort}
|
||||
onChange={(e) => setProxyPort(e.target.value)}
|
||||
placeholder="Port"
|
||||
className="w-16 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyUsername}
|
||||
onChange={(e) => setProxyUsername(e.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={proxyPassword}
|
||||
onChange={(e) => setProxyPassword(e.target.value)}
|
||||
placeholder="Password (optional)"
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setProxyAccountId(null)}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from "@/shared/constants/providers";
|
||||
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
|
||||
import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings";
|
||||
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
@@ -831,14 +832,10 @@ function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}):
|
||||
}
|
||||
|
||||
/**
|
||||
* #4954 — A no-auth provider ("OpenCode Free", MiMoCode, …) has no DB-backed
|
||||
* credential, but its NoAuthAccountCard DOES persist a real connection row whose
|
||||
* `providerSpecificData` carries the per-account proxy/rotation config
|
||||
* (`fingerprints` + `accountProxies`). The synthetic credentials returned above
|
||||
* default to an empty `providerSpecificData`, so without hydration the executor
|
||||
* never sees those proxies and every request egresses direct. Pull just the
|
||||
* rotation-relevant fields off the active connection so the executor can honor
|
||||
* them. Best-effort: any read failure falls back to empty (historical behavior).
|
||||
* #4954 / #5217 (Gap 1) — no-auth providers persist a connection row whose
|
||||
* `providerSpecificData` carries `fingerprints` + `accountProxies`. Hydrate those
|
||||
* and resolve by-id Proxy Pool references to live records (./noAuthProxyResolution)
|
||||
* so the executor gets a resolved inline `proxy`. Best-effort: failures → empty.
|
||||
*/
|
||||
async function loadNoAuthProviderSpecificData(providerId: string): Promise<JsonRecord> {
|
||||
try {
|
||||
@@ -857,6 +854,9 @@ async function loadNoAuthProviderSpecificData(providerId: string): Promise<JsonR
|
||||
hydrated.accountProxies = psd.accountProxies;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(hydrated.accountProxies)) {
|
||||
hydrated.accountProxies = await resolveAccountProxiesFromRegistry(hydrated.accountProxies);
|
||||
}
|
||||
return hydrated;
|
||||
} catch {
|
||||
return {};
|
||||
|
||||
111
src/sse/services/noAuthProxyResolution.ts
Normal file
111
src/sse/services/noAuthProxyResolution.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { getProxyById } from "@/lib/db/proxies";
|
||||
|
||||
/**
|
||||
* #5217 (Gap 1) — Per-account proxy resolution for no-auth providers
|
||||
* ("OpenCode Free", MiMoCode, …).
|
||||
*
|
||||
* The NoAuthAccountCard now stores a proxy *reference* (by id) per account so a
|
||||
* single edit to a Proxy Pool entry applies to every account that references it,
|
||||
* instead of forcing the operator to retype host/port/credentials per card. A
|
||||
* one-off "custom" inline proxy is still supported as an escape hatch.
|
||||
*
|
||||
* Persisted shape per account (`providerSpecificData.accountProxies[]`):
|
||||
* - by-id reference (Proxy Pool dropdown): `{ fingerprint, proxyId }`
|
||||
* - custom escape hatch (manual inputs): `{ fingerprint, proxy: {…} }`
|
||||
* - legacy (pre-Gap-1): `{ fingerprint, proxy: {…} }`
|
||||
*
|
||||
* This module resolves those entries into the inline `{ fingerprint, proxy }`
|
||||
* shape the executors already consume, so the executor stays unchanged:
|
||||
* - `proxyId` is looked up in the proxy registry and hydrated to its live
|
||||
* `{ type, host, port, username?, password? }` record;
|
||||
* - an inline `proxy` (custom / legacy) passes through unchanged;
|
||||
* - an unknown/deleted `proxyId` (or any read failure) degrades to `proxy: null`
|
||||
* (direct egress) — never throws.
|
||||
*/
|
||||
|
||||
export interface ResolvedAccountProxy {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AccountProxyEntry {
|
||||
fingerprint: string;
|
||||
proxy?: Partial<ResolvedAccountProxy> | null;
|
||||
proxyId?: string | null;
|
||||
}
|
||||
|
||||
interface ProxyRegistryRecordLike {
|
||||
type?: string;
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
/** Async lookup of a proxy registry record by id (null when absent). */
|
||||
export type ProxyByIdLookup = (proxyId: string) => Promise<ProxyRegistryRecordLike | null>;
|
||||
|
||||
function normalizeRecord(rec: ProxyRegistryRecordLike | Partial<ResolvedAccountProxy>) {
|
||||
const host = typeof rec.host === "string" ? rec.host.trim() : "";
|
||||
if (!host) return null;
|
||||
const username = typeof rec.username === "string" ? rec.username : "";
|
||||
const password = typeof rec.password === "string" ? rec.password : "";
|
||||
const resolved: ResolvedAccountProxy = {
|
||||
type: typeof rec.type === "string" && rec.type ? rec.type : "socks5",
|
||||
host,
|
||||
port: Number(rec.port) || 0,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
};
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve raw `accountProxies` entries to inline `{ fingerprint, proxy }`. Pure
|
||||
* over the injected `lookup` so it is unit-testable without a database.
|
||||
*/
|
||||
export async function resolveAccountProxies(
|
||||
entries: unknown,
|
||||
lookup: ProxyByIdLookup
|
||||
): Promise<Array<{ fingerprint: string; proxy: ResolvedAccountProxy | null }>> {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out: Array<{ fingerprint: string; proxy: ResolvedAccountProxy | null }> = [];
|
||||
for (const raw of entries) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const entry = raw as AccountProxyEntry;
|
||||
if (typeof entry.fingerprint !== "string") continue;
|
||||
|
||||
// By-id reference (Proxy Pool): resolve to the live record so a pool edit
|
||||
// propagates to every referencing account. Unknown/deleted id → direct.
|
||||
if (typeof entry.proxyId === "string" && entry.proxyId) {
|
||||
let record: ProxyRegistryRecordLike | null = null;
|
||||
try {
|
||||
record = await lookup(entry.proxyId);
|
||||
} catch {
|
||||
record = null;
|
||||
}
|
||||
out.push({
|
||||
fingerprint: entry.fingerprint,
|
||||
proxy: record ? normalizeRecord(record) : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Custom escape hatch / legacy inline proxy: pass through unchanged.
|
||||
if (entry.proxy && typeof entry.proxy === "object") {
|
||||
out.push({ fingerprint: entry.fingerprint, proxy: normalizeRecord(entry.proxy) });
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ fingerprint: entry.fingerprint, proxy: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Production binding: resolves by-id references against the proxy registry. */
|
||||
export async function resolveAccountProxiesFromRegistry(entries: unknown) {
|
||||
return resolveAccountProxies(entries, (id) => getProxyById(id, { includeSecrets: true }));
|
||||
}
|
||||
93
tests/unit/noauth-proxy-resolution.test.ts
Normal file
93
tests/unit/noauth-proxy-resolution.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveAccountProxies,
|
||||
type ProxyByIdLookup,
|
||||
} from "../../src/sse/services/noAuthProxyResolution.ts";
|
||||
|
||||
// #5217 (Gap 1): per-account Proxy Pool reference resolution.
|
||||
// The OpenCode Free per-account proxy modal now stores a proxy REFERENCE (proxyId)
|
||||
// instead of forcing manual host/port re-entry. The server resolves that id to the
|
||||
// live pool record so the executor still receives an inline {type,host,port,...}.
|
||||
|
||||
const POOL: Record<string, { type: string; host: string; port: number; username?: string; password?: string }> = {
|
||||
"pool-1": { type: "http", host: "1.2.3.4", port: 8080, username: "u", password: "p" },
|
||||
"pool-2": { type: "socks5", host: "9.9.9.9", port: 1080 },
|
||||
};
|
||||
|
||||
const lookup: ProxyByIdLookup = async (id) => POOL[id] ?? null;
|
||||
|
||||
test("by-id reference resolves to the pool record (type/host/port/credentials)", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-a", proxyId: "pool-1" }], lookup);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].fingerprint, "acc-a");
|
||||
assert.deepEqual(out[0].proxy, {
|
||||
type: "http",
|
||||
host: "1.2.3.4",
|
||||
port: 8080,
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
});
|
||||
|
||||
test("by-id reference without credentials omits username/password", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-b", proxyId: "pool-2" }], lookup);
|
||||
assert.deepEqual(out[0].proxy, { type: "socks5", host: "9.9.9.9", port: 1080 });
|
||||
});
|
||||
|
||||
test("inline custom proxy passes through unchanged (escape hatch / legacy)", async () => {
|
||||
const inline = { type: "https", host: "5.6.7.8", port: 3128, username: "x", password: "y" };
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-c", proxy: inline }], lookup);
|
||||
assert.deepEqual(out[0].proxy, inline);
|
||||
});
|
||||
|
||||
test("unknown / deleted proxyId degrades safely to direct (null), no crash", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-d", proxyId: "gone" }], lookup);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
|
||||
test("a throwing lookup degrades to direct (null) rather than rejecting", async () => {
|
||||
const throwing: ProxyByIdLookup = async () => {
|
||||
throw new Error("db down");
|
||||
};
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-e", proxyId: "pool-1" }], throwing);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
|
||||
test("proxyId takes precedence over an inline proxy on the same entry", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-f", proxyId: "pool-2", proxy: { type: "http", host: "0.0.0.0", port: 1 } }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy?.host, "9.9.9.9");
|
||||
});
|
||||
|
||||
test("entry with neither proxyId nor proxy.host yields direct (null)", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-g" }, { fingerprint: "acc-h", proxy: null }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy, null);
|
||||
assert.equal(out[1].proxy, null);
|
||||
});
|
||||
|
||||
test("non-array / malformed input is ignored without throwing", async () => {
|
||||
assert.deepEqual(await resolveAccountProxies(undefined, lookup), []);
|
||||
assert.deepEqual(await resolveAccountProxies(null, lookup), []);
|
||||
const out = await resolveAccountProxies(
|
||||
[null, 42, "x", { proxyId: "pool-1" }, { fingerprint: "ok", proxyId: "pool-2" }],
|
||||
lookup
|
||||
);
|
||||
// Only the well-formed entry (with a string fingerprint) survives.
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].fingerprint, "ok");
|
||||
});
|
||||
|
||||
test("inline proxy missing host is treated as direct (null)", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-i", proxy: { type: "http", port: 8080 } as never }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
@@ -139,7 +139,110 @@ describe("NoAuthAccountCard compact grid", () => {
|
||||
});
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
expect(el.textContent).toContain("Proxy for Account 1");
|
||||
// The editor exposes a host input (the proxy configuration surface).
|
||||
// No saved proxies in this fixture → editor falls back to the custom inputs.
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── #5217 (Gap 1): Proxy Pool dropdown (by-id reference) ──────────────────────
|
||||
|
||||
const SAVED_PROXIES = [
|
||||
{ id: "pool-1", name: "US East", type: "socks5", host: "1.2.3.4", port: 1080, status: "active" },
|
||||
{ id: "pool-2", name: "EU West", type: "http", host: "9.9.9.9", port: 8080, status: "active" },
|
||||
];
|
||||
|
||||
function setupFetchWithProxies(fingerprints: string[], accountProxies: unknown[] = []) {
|
||||
const connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
provider: PROVIDER_ID,
|
||||
providerSpecificData: { fingerprints, accountProxies },
|
||||
},
|
||||
];
|
||||
const putBodies: unknown[] = [];
|
||||
const mockFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/settings/proxies")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: SAVED_PROXIES }),
|
||||
} as Response);
|
||||
}
|
||||
if (u.includes("/api/providers")) {
|
||||
if (init?.method === "PUT" && typeof init.body === "string") {
|
||||
putBodies.push(JSON.parse(init.body));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ connections }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
return { mockFetch, putBodies };
|
||||
}
|
||||
|
||||
describe("NoAuthAccountCard proxy pool dropdown (#5217 Gap 1)", () => {
|
||||
it("defaults the editor to the Saved Proxy Pool dropdown when pool proxies exist", async () => {
|
||||
setupFetchWithProxies(makeFingerprints(2));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
// The saved dropdown lists every pool proxy by name; the manual Host input is hidden.
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select).toBeTruthy();
|
||||
expect(el.textContent).toContain("US East");
|
||||
expect(el.textContent).toContain("EU West");
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a by-id reference {fingerprint, proxyId} when a pool proxy is selected", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
const { putBodies } = setupFetchWithProxies(fps);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.querySelector("select") !== null);
|
||||
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(select, "pool-2");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
await waitForCondition(() => putBodies.length > 0);
|
||||
const body = putBodies.at(-1) as { providerSpecificData?: { accountProxies?: unknown[] } };
|
||||
const stored = body.providerSpecificData?.accountProxies ?? [];
|
||||
expect(stored).toEqual([{ fingerprint: fps[0], proxyId: "pool-2" }]);
|
||||
});
|
||||
|
||||
it("lights the shield for an account stored as a by-id reference", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
setupFetchWithProxies(fps, [{ fingerprint: fps[0], proxyId: "pool-1" }]);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const firstShield = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
// Tooltip is resolved from the referenced pool record, not an inline proxy.
|
||||
expect(firstShield.getAttribute("title")).toContain("1.2.3.4");
|
||||
expect(firstShield.className).toContain("text-blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user