fix(zed): harden OAuth lifecycle and live model support

- executors/zed.js: use exact wire values (anthropic, open_ai, google, x_ai)
  and strip incompatible Vertex safetySettings on the Google path
- shared/zedAuth.js: robust callback query parsing, reject garbage PKCS#1 v1.5
  decryptions, and thread proxyOptions when fetching LLM tokens
- oauth: preserve systemId across authorize/register/exchange lifecycle,
  renew proxy idle timeout on reuse, and ignore non-callback localhost requests
- shared/OAuthModal.js: track owned proxy in flowRef and stop at most once
- api/providers/[id]/models: add connection-scoped live Zed model resolver
- registry: unhide provider in dashboard
- tests: add unit coverage for wire format, native auth, and live models
This commit is contained in:
Mosabbir Maruf
2026-09-17 18:46:13 +07:00
parent 725e2c1187
commit ef18175226
13 changed files with 866 additions and 105 deletions

View File

@@ -71,6 +71,8 @@ export default function ProviderDetailPage() {
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]);
const [liveModels, setLiveModels] = useState([]);
// Live-catalog fetch warning/error (surfaced for zed only; cursor behavior unchanged).
const [liveModelsError, setLiveModelsError] = useState(null);
const [kiloFreeModels, setKiloFreeModels] = useState([]);
const [disabledModelIds, setDisabledModelIds] = useState([]);
const [confirmState, setConfirmState] = useState(null);
@@ -153,7 +155,7 @@ export default function ProviderDetailPage() {
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
const staticModels = getModelsByProviderId(providerId);
const models = providerId === "cursor" && liveModels.length > 0
const models = (providerId === "cursor" || providerId === "zed") && liveModels.length > 0
? liveModels
: staticModels;
const providerAlias = getProviderAlias(providerId);
@@ -467,11 +469,13 @@ export default function ProviderDetailPage() {
fetchDisabledModels();
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Cursor's model availability is account-specific and changes frequently.
// Load the active account's live catalog for the dashboard; the static
// registry remains the fallback while the request is pending or unavailable.
// Live per-connection catalogs (cursor, zed): the static registry carries
// no usable list, so resolve from the active connection. Fires only when
// the provider id or connection list changes — no polling, no loop.
// Cursor path is statement-identical to before; zed adds error surfacing.
useEffect(() => {
if (providerId !== "cursor") {
const isLiveCatalog = providerId === "cursor" || providerId === "zed";
if (!isLiveCatalog) {
setLiveModels([]);
return;
}
@@ -479,18 +483,32 @@ export default function ProviderDetailPage() {
const connection = connections.find((item) => item.isActive !== false);
if (!connection?.id) {
setLiveModels([]);
if (providerId === "zed") setLiveModelsError(null);
return;
}
let cancelled = false;
if (providerId === "zed") setLiveModelsError(null);
fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" })
.then(async (res) => ({ ok: res.ok, data: await res.json() }))
.then(async (res) => ({ ok: res.ok, data: await res.json().catch(() => null) }))
.then(({ ok, data }) => {
if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) {
if (cancelled) return;
if (ok && Array.isArray(data?.models) && data.models.length > 0) {
setLiveModels(data.models);
if (providerId === "zed" && data?.warning) setLiveModelsError(data.warning);
return;
}
if (providerId === "zed") {
setLiveModels([]);
setLiveModelsError(data?.warning || data?.error || "Zed returned no live models.");
}
})
.catch(() => {});
.catch(() => {
if (!cancelled && providerId === "zed") {
setLiveModels([]);
setLiveModelsError("Failed to reach the Zed model catalog.");
}
});
return () => { cancelled = true; };
}, [providerId, connections]);
@@ -1767,6 +1785,9 @@ export default function ProviderDetailPage() {
{!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
)}
{providerId === "zed" && !!liveModelsError && (
<p className="text-xs text-red-500 mb-3 break-words">{liveModelsError}</p>
)}
{renderModelsSection()}
</Card>

View File

@@ -309,13 +309,13 @@ export async function POST(request, { params }) {
let ok = false;
if (provider === "trae") ok = registerTraeSession({ state });
else if (provider === "windsurf") ok = registerWindsurfSession({ state });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier, systemId: body?.systemId });
else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: ok });
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body;
const { code, redirectUri, codeVerifier, state, meta, systemId } = body;
// Xiaomi MiMo: no token exchange needed — the callback already decrypted the sk.
// Just read the session result and create the connection.
@@ -459,8 +459,13 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl).
// systemId (Zed) is merged into meta so the login attempt's own id is
// used instead of a freshly prepared one. Ignored by other providers.
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, {
...(meta || {}),
...(systemId ? { systemId } : {}),
});
// Save to database
const connection = await createProviderConnection({

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { GEMINI_CONFIG, ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
@@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { resolveClineModels, resolveClinepassModels } from "open-sse/services/clinepassModels.js";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
@@ -287,6 +288,44 @@ const PROVIDER_MODELS_CONFIG = {
};
},
},
// Zed has no static catalog by design (live /models only) — same cursor
// direct pattern: resolve with the connection's own credentials (never
// exposed to the browser), return rich metadata, drop disabled entries.
// Empty/failure yields an explicit warning, never a silent zero list.
zed: {
customResolver: async (connection) => {
try {
const result = await resolveZedModels({
accessToken: connection.accessToken,
providerSpecificData: connection.providerSpecificData || {},
}, { config: ZED_HOSTED_CONFIG, forceRefresh: true });
const models = (result?.models || [])
.filter((m) => m && !m.isDisabled)
.map((m) => ({
id: m.id,
name: m.name || m.id,
provider: m.provider,
contextLength: m.contextLength,
contextLengthInMaxMode: m.contextLengthInMaxMode,
maxOutputTokens: m.maxOutputTokens,
supportsTools: m.supportsTools,
supportsImages: m.supportsImages,
supportsThinking: m.supportsThinking,
supportsDisablingThinking: m.supportsDisablingThinking,
supportsFastMode: m.supportsFastMode,
supportsServerSideCompaction: m.supportsServerSideCompaction,
supportedEffortLevels: m.supportedEffortLevels || [],
supportsStreamingTools: m.supportsStreamingTools,
supportsParallelToolCalls: m.supportsParallelToolCalls,
}));
if (models.length > 0) return { models };
return { models: [], warning: "Zed returned no live models." };
} catch (error) {
console.log("Failed to fetch Zed models dynamically:", error.message);
return { models: [], warning: `Failed to fetch Zed models: ${error.message}` };
}
},
},
// Cline/ClinePass share api.cline.bot/api/v1/models. The service layer already
// handles Bearer-vs-`workos:` auth and swallows failures into null, so these follow

View File

@@ -112,6 +112,11 @@ export async function generateAuthData(providerName, redirectUri, meta) {
flowType: provider.flowType,
fixedPort: provider.fixedPort,
callbackPath: provider.callbackPath || "/callback",
// Zed: surface the system_id embedded in the sign-in URL so the frontend
// can thread it through register-session → exchange → stored connection
// (exchangeTokens re-runs prepareConfig, which would otherwise mint a
// different one). Absent for every other provider — purely additive.
...(config.systemId ? { systemId: config.systemId } : {}),
};
}

View File

@@ -21,11 +21,15 @@ const zed = {
return { ...config, ...auth };
},
buildAuthUrl: (config, redirectUri, state) => config.authUrl,
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta) => {
// code = raw callback URL/query; codeVerifier = encoded private key verifier.
const { userId, encryptedAccessToken } = parseZedCallbackPayload(code);
const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier);
return { accessToken, userId, systemId: config.systemId };
// Prefer the system_id registered for this login attempt (threaded via
// meta from register-session); fall back to the prepared config. Never
// mint a fresh one here — exchangeTokens re-runs prepareConfig, which
// would otherwise store a system_id unrelated to the zed.dev login.
return { accessToken, userId, systemId: meta?.systemId || config.systemId };
},
postExchange: async (tokens) => {
const credentials = {

View File

@@ -648,9 +648,15 @@ let zedProxyTimeout = null;
let zedProxyPort = null;
let zedSession = null;
export function registerZedSession({ state, codeVerifier }) {
export function registerZedSession({ state, codeVerifier, systemId }) {
if (!state || !codeVerifier) return false;
zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() };
zedSession = {
state,
codeVerifier,
systemId: systemId || null,
status: "pending",
createdAt: Date.now(),
};
return true;
}
export function getZedSessionStatus(state) {
@@ -665,6 +671,10 @@ export function clearZedSession(state) {
export function startZedProxy(preferredPort = 0) {
return new Promise((resolve) => {
if (zedProxyServer) {
// Reuse the live listener, but renew its idle timeout so a previous
// flow's deadline can never kill the flow that just adopted the port.
if (zedProxyTimeout) clearTimeout(zedProxyTimeout);
zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs);
resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` });
return;
}
@@ -694,13 +704,34 @@ export function startZedProxy(preferredPort = 0) {
res.end(renderCodexResultPage(false, "Cross-origin callback rejected"));
return;
}
// A genuine Zed redirect always carries user_id + access_token. Anything
// else (probe, prefetch, stray navigation, favicon-style miss) is NOT
// the callback: answer without touching the session and WITHOUT
// stopping the server, so the real redirect can still land afterwards.
const qp = url.searchParams;
const hasZedParams =
qp.has("user_id") || qp.has("userId") ||
qp.has("access_token") || qp.has("accessToken") || qp.has("token");
if (!hasZedParams) {
console.log(`[Zed proxy] ignoring non-callback ${req.method} ${url.pathname} (session kept, server kept)`);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "Waiting for Zed sign-in — this request carried no login data."));
return;
}
// Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload.
// codeVerifier carries the encoded RSA private key for decryption.
const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname;
try {
const { exchangeTokens } = await import("../providers.js");
const { createProviderConnection } = await import("@/models");
const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state);
const tokenData = await exchangeTokens(
"zed",
rawCallback,
null,
session.codeVerifier,
session.state,
session.systemId ? { systemId: session.systemId } : undefined,
);
const connection = await createProviderConnection({
provider: "zed",
authType: "oauth",
@@ -712,13 +743,16 @@ export function startZedProxy(preferredPort = 0) {
session.email = connection.email;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(true, "You can close this window."));
stopZedProxy();
} catch (err) {
session.status = "error";
session.error = err.message;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, err.message));
} finally {
stopZedProxy();
// Intentionally NOT stopping here: the failure may belong to a
// superseded attempt (e.g. an older popup landing after "Try Again"
// registered a new keypair). The live attempt's genuine callback must
// still land. The idle timeout + modal close bound the listener.
}
});
const tryPort = Number(preferredPort) || 0;

View File

@@ -50,6 +50,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const popupRef = useRef(null);
const pollingAbortRef = useRef(false);
const openedRef = useRef(false);
// Proxy-flow session ledger: which provider's proxy THIS modal session
// started, and whether its stop was already sent. Every stop-proxy call is
// gated on this — parent re-renders can never spam it, and a close stops
// the owned proxy exactly once.
const flowRef = useRef({ proxyStarted: false, proxyProvider: null, stopSent: false });
// Parent callbacks are stored in refs so effect/callback identities stay
// stable across parent re-renders (the page passes fresh inline closures).
// Synced by the ref-sync effect below (placed after all callbacks are
// defined); the open effect then depends only on stable primitives.
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
const isOpenRef = useRef(isOpen);
const startOAuthFlowRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
// State for client-only values to avoid hydration mismatch
@@ -81,6 +94,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
redirectUri: authData.redirectUri,
codeVerifier: authData.codeVerifier,
state,
// Zed: thread the login attempt's system_id so the stored
// connection keeps the id sent to zed.dev (see register-session).
...(authData.systemId ? { systemId: authData.systemId } : {}),
...(oauthMeta ? { meta: oauthMeta } : {}),
}),
});
@@ -89,12 +105,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
} catch (err) {
setError(err.message);
setStep("error");
}
}, [authData, provider, onSuccess, oauthMeta]);
}, [authData, provider, oauthMeta]);
const completeXaiManualCode = useCallback(async (code) => {
if (!authData?.state) return;
@@ -108,12 +124,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
} catch (err) {
setError(err.message);
setStep("error");
}
}, [authData, onSuccess]);
}, [authData]);
// Poll for device code token
const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData, deadlineMs) => {
@@ -155,7 +171,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
pollingAbortRef.current = true; // Stop polling immediately
setStep("success");
setPolling(false);
onSuccess?.();
onSuccessRef.current?.();
return;
}
@@ -177,9 +193,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError("Authorization timeout");
setStep("error");
setPolling(false);
}, [provider, onSuccess]);
}, [provider]);
// Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange.
// Stop the proxy owned by THIS modal session, at most once. Re-renders,
// repeated closes, and post-completion calls are all no-ops by construction.
const stopOwnedProxy = useCallback(() => {
const flow = flowRef.current;
if (flow.proxyStarted && !flow.stopSent && flow.proxyProvider) {
flow.stopSent = true;
fetch(`/api/oauth/${flow.proxyProvider}/stop-proxy`).catch(() => {});
}
}, []);
// Trae/Windsurf/Zed proxy OAuth flow: dynamic-port local callback → auto exchange.
const startProxyFlow = useCallback(async (providerId) => {
// 1. Start the local callback server (returns a dynamic port + callback URL).
const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`);
@@ -187,31 +213,61 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`);
}
// Take ownership immediately so a close during the remaining flight still
// cleans this proxy up (via the close effect or the abort below).
flowRef.current.proxyStarted = true;
flowRef.current.proxyProvider = providerId;
flowRef.current.stopSent = false;
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
// 2. Build the authorize URL with redirect_uri = proxy callback URL.
const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const authData = await authRes.json();
if (!authRes.ok) throw new Error(authData.error);
if (!authRes.ok) {
stopOwnedProxy();
throw new Error(authData.error);
}
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
// 3. Register the session so the proxy can match the incoming callback.
// Zed also passes code_verifier (encodes the RSA private key for decrypt);
// sent via POST body so the private key never lands in URL/query logs.
// Zed also passes code_verifier (encodes the RSA private key for decrypt)
// + systemId; sent via POST body so secrets never land in URL/query logs.
const regBody = { state: authData.state };
if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier;
await fetch(`/api/oauth/${providerId}/register-session`, {
if (authData.systemId) regBody.systemId = authData.systemId;
const regRes = await fetch(`/api/oauth/${providerId}/register-session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
let regData = null;
try {
regData = await regRes.json();
} catch {
regData = null;
}
if (!regRes.ok || regData?.success === false) {
stopOwnedProxy();
throw new Error(regData?.error || "Failed to register login session; please retry");
}
if (!isOpenRef.current) return; // closed mid-flight: close effect owns cleanup now
// 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status.
setAuthData({ ...authData, proxyProvider: providerId });
setStep("waiting");
popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700");
if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste
}, []);
}, [stopOwnedProxy]);
// Start OAuth flow
const startOAuthFlow = useCallback(async () => {
// Start OAuth flow (plain function by design: it is only invoked from the
// open effect via ref and from user actions, so memoization would only add
// an identity that re-triggers effects on every parent re-render).
const startOAuthFlow = async () => {
if (!provider) return;
try {
setError(null);
@@ -356,6 +412,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
// Take ownership of server-side proxies so close stops them exactly once
// (replaces the per-provider stop branches; same behavior, one ledger).
if ((provider === "codex" && codexProxyActive) || (provider === "xai" && xaiProxyActive)) {
flowRef.current.proxyStarted = true;
flowRef.current.proxyProvider = provider;
flowRef.current.stopSent = false;
}
// Guard: device_code providers return authUrl:null from /authorize. Never window.open(null)
// (browsers coerce it to the relative path ".../null").
if (!data.authUrl) {
@@ -396,49 +460,55 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]);
};
// Reset state and start OAuth when modal opens
// Sync latest props/flow into refs after every render (no dep array).
// The open effect below then depends only on stable primitives.
useEffect(() => {
if (isOpen && provider) {
// Guard against StrictMode/effect re-runs auto-opening multiple tabs.
if (openedRef.current) return;
openedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
startOAuthFlow();
} else if (!isOpen) {
// Abort polling and cleanup proxy when modal closes
pollingAbortRef.current = true;
openedRef.current = false;
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onSuccessRef.current = onSuccess;
onCloseRef.current = onClose;
isOpenRef.current = isOpen;
startOAuthFlowRef.current = startOAuthFlow;
});
// Reset state and start OAuth when modal opens — exactly once per open.
// Guarded by openedRef so StrictMode/effect re-runs never open extra tabs.
useEffect(() => {
if (!isOpen || !provider) return;
if (openedRef.current) return;
openedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: false };
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
}, [isOpen, provider, startOAuthFlow]);
startOAuthFlowRef.current();
}, [isOpen, provider]);
// Cleanup when the modal closes: abort polling and stop the proxy THIS
// session started, exactly once. Deps are stable primitives, so unrelated
// parent re-renders cannot reach the stop call (previously every parent
// render re-fired stop-proxy while the modal was closed).
useEffect(() => {
if (isOpen) return;
pollingAbortRef.current = true;
openedRef.current = false;
stopOwnedProxy();
flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: false };
}, [isOpen, provider, stopOwnedProxy]);
// Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port):
// poll status until the proxy auto-exchanges and saves the connection.
@@ -467,7 +537,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (data.status === "done") {
callbackProcessedRef.current = true;
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
if (data.status === "error") {
@@ -489,7 +559,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
};
setTimeout(tick, POLL_INTERVAL_MS);
return () => { cancelled = true; };
}, [authData, onSuccess]);
}, [authData]);
// Listen for OAuth callback via multiple methods
useEffect(() => {
@@ -589,23 +659,31 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
const input = callbackUrl.trim();
// Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL
// Trae/Windsurf/Zed proxy flow fallback (popup blocked): paste the full callback URL
if (PROXY_OAUTH_PROVIDERS.has(provider) && input) {
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: input, state: authData?.state }),
body: JSON.stringify({
code: input,
state: authData?.state,
// Zed manual fallback needs the same attempt material as the
// automatic path (redirectUri + RSA verifier + system_id).
...(authData?.redirectUri ? { redirectUri: authData.redirectUri } : {}),
...(authData?.codeVerifier ? { codeVerifier: authData.codeVerifier } : {}),
...(authData?.systemId ? { systemId: authData.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
@@ -652,21 +730,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
};
// Clear session on modal close + cleanup proxy
// Clear session on modal close + cleanup proxy (idempotent: the owned
// proxy is stopped at most once across effect-close, button-close, and
// Escape/backdrop-close — all funnel through here or the close effect).
const handleClose = useCallback(() => {
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onClose();
}, [onClose, provider]);
stopOwnedProxy();
onCloseRef.current();
}, [stopOwnedProxy]);
if (!provider || !providerInfo) return null;
const isXaiProvider = provider === "xai";