feat(opencode): Proxy Pool dropdown (by-id) for OpenCode Free account proxies (#5217 Gap 1) (#5370)

Integrated into release/v3.8.41 — Proxy Pool dropdown by-id for account proxies (#5217 Gap 1).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 12:19:26 -03:00
committed by GitHub
parent 734061f736
commit 24f178f3fc
6 changed files with 514 additions and 94 deletions

View File

@@ -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))

View File

@@ -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)}

View File

@@ -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 {};

View 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 }));
}

View 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);
});

View File

@@ -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");
});
});