diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e77ddfdac..ddb21a738d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` close-marker via the request header `x-omniroute-thinking-marker: off` so it no longer leaks an orphan `` 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 --tls-key ` (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 ``-contract system message + `{…}` 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)) diff --git a/src/shared/components/NoAuthAccountCard.tsx b/src/shared/components/NoAuthAccountCard.tsx index c02b5fa22b..d4e182c5c4 100644 --- a/src/shared/components/NoAuthAccountCard.tsx +++ b/src/shared/components/NoAuthAccountCard.tsx @@ -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(null); + const [proxyMode, setProxyMode] = useState<"saved" | "custom">("saved"); + const [savedProxies, setSavedProxies] = useState([]); + 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 (
-
- 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" + > + + {savedProxies.map((p) => ( + ))} - 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" - /> - 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" - /> -
- 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" - /> - 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" - /> + ) : ( + <> +
+ + 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" + /> + 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" + /> +
+ 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" + /> + 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" + /> + + )}