diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js index e6233fcb..b88d2762 100644 --- a/open-sse/executors/zed.js +++ b/open-sse/executors/zed.js @@ -29,11 +29,18 @@ import { zedLlmFetch, } from "../shared/zedAuth.js"; +// Wire values for the `provider` field of POST /completions. These are NOT +// display names: cloud.zed.dev matches them exactly, and an unrecognized value +// fails the whole request with `500 {"message":"An internal server error +// occurred."}` before the model is ever looked at. Spellings come from Zed's +// own GET /models catalog: `anthropic`, `open_ai`, `google` (note underscore), +// `x_ai` follows the same convention — so feeding a catalog value back through +// normalizeZedProvider is identity. const ZED_PROVIDER = { - anthropic: "Anthropic", - openai: "OpenAi", - google: "Google", - xai: "XAi", + anthropic: "anthropic", + openai: "open_ai", + google: "google", + xai: "x_ai", }; function normalizeZedProvider(value, model) { @@ -55,7 +62,14 @@ function buildProviderRequest(provider, model, body, stream, credentials) { return openaiToClaudeRequest(model, body, true); } if (provider === ZED_PROVIDER.google) { - return openaiToGeminiRequest(model, body, true); + const geminiRequest = openaiToGeminiRequest(model, body, true); + // Zed's hosted Gemini backend speaks the Vertex safety vocabulary, not the + // public Gemini API enum the shared translator emits (`OFF`, `CIVIC_INTEGRITY`, + // `DANGEROUS_CONTENT`). Drop client-side safetySettings for the Zed Google + // path so Zed applies its own defaults — scoped here so native Gemini/ + // Antigravity is untouched. + delete geminiRequest.safetySettings; + return geminiRequest; } if (provider === ZED_PROVIDER.openai) { return openaiToOpenAIResponsesRequest(model, body, true, credentials); diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js index 9224cf95..7e2e5dda 100644 --- a/open-sse/providers/registry/zed.js +++ b/open-sse/providers/registry/zed.js @@ -4,7 +4,6 @@ export default { priority: 10, alias: "zd", uiAlias: "zd", - hidden: true, display: { name: "Zed", icon: "code", diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index e3d8371a..4bfc33bc 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -112,7 +112,14 @@ export function parseZedCallbackPayload(input) { url = new URL(raw); } catch { try { - url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + // Accept pathname+query (what the local proxy forwards, e.g. + // "/?user_id=..&access_token=.." or "/callback?.."), a bare query, + // or a lone query string. Only the query part is parsed — a leading + // path must never become part of the first parameter name. + const query = raw.includes("?") + ? raw.slice(raw.indexOf("?") + 1) + : raw.replace(/^\?/, ""); + url = new URL(`http://127.0.0.1/?${query}`); } catch { throw new Error("Invalid Zed callback URL"); } @@ -134,6 +141,10 @@ export function parseZedCallbackPayload(input) { export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + const fail = (oaepError) => { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + }; try { return crypto .privateDecrypt( @@ -143,15 +154,21 @@ export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) .toString("utf8"); } catch (oaepError) { try { - return crypto + const text = crypto .privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encrypted, ) .toString("utf8"); - } catch { - const message = oaepError instanceof Error ? oaepError.message : String(oaepError); - throw new Error(`Failed to decrypt Zed access token: ${message}`); + // PKCS#1 v1.5 unpadding is not integrity-checked: a wrong-key decrypt + // can "succeed" with garbage bytes instead of throwing. Replacement + // characters prove the output is not the real UTF-8 token — fail loudly + // rather than storing garbage as a credential. + if (text.includes("�")) fail(oaepError); + return text; + } catch (err) { + if (err.message.startsWith("Failed to decrypt Zed access token")) throw err; + fail(oaepError); } } } @@ -280,6 +297,7 @@ export async function fetchZedLlmToken(credentials, options = {}) { body: JSON.stringify({ organization_id: organizationId }), signal: options.signal ?? undefined, }, + options.proxyOptions ?? null, ); const token = typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 5ae217fb..437214e2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 && (

{modelsTestError}

)} + {providerId === "zed" && !!liveModelsError && ( +

{liveModelsError}

+ )} {renderModelsSection()} diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 12fb9d66..520be970 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -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({ diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 605bc5ca..23f97380 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -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 diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index e505b01d..8ba6c8cb 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -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 } : {}), }; } diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js index 3343976a..3f38bf87 100644 --- a/src/lib/oauth/providers/zed.js +++ b/src/lib/oauth/providers/zed.js @@ -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 = { diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 80377752..83de1821 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -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; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 805301da..589a0415 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -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"; diff --git a/tests/unit/zed-completions-wire.test.js b/tests/unit/zed-completions-wire.test.js new file mode 100644 index 00000000..5be982ae --- /dev/null +++ b/tests/unit/zed-completions-wire.test.js @@ -0,0 +1,121 @@ +// Zed completions wire acceptance: the `provider` field of POST /completions +// must use cloud.zed.dev's exact wire values (anthropic/open_ai/google/x_ai), +// and the Zed Gemini path must not carry the shared translator's +// safetySettings (Zed's hosted Gemini backend speaks the Vertex safety +// vocabulary, not the public-Gemini enums). +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("open-sse/shared/zedAuth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveZedModels: vi.fn(), + zedLlmFetch: vi.fn(), + }; +}); + +import { + resolveZedModels, + zedLlmFetch, +} from "open-sse/shared/zedAuth.js"; +import ZedExecutor from "open-sse/executors/zed.js"; + +function catalogFor(entries) { + const rawById = new Map(entries); + return { rawById, models: [] }; +} + +function mockCatalogFetch(captured) { + zedLlmFetch.mockImplementation(async (credentials, path, options) => { + captured.body = JSON.parse(options.fetchOptions.body); + return new Response("upstream-error-stub", { status: 500 }); + }); +} + +function makeExecutor() { + const executor = new ZedExecutor(); + executor.config = {}; + return executor; +} + +const CHAT_BODY = { messages: [{ role: "user", content: "hi" }] }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("wire provider enum", () => { + it.each([ + ["Anthropic", "anthropic"], + ["anthropic", "anthropic"], + ["OpenAi", "open_ai"], + ["open_ai", "open_ai"], + ["Google", "google"], + ["gemini", "google"], + ["XAi", "x_ai"], + ["x_ai", "x_ai"], + ])("catalog provider %j normalizes to wire %j", async (catalogValue, wire) => { + resolveZedModels.mockResolvedValue(catalogFor([["m", { provider: catalogValue }]])); + const executor = makeExecutor(); + const { provider } = await executor.resolveModel("m", {}, null, null); + expect(provider).toBe(wire); + }); + + it("infers wire provider from the model id when the catalog is unavailable", async () => { + resolveZedModels.mockRejectedValue(new Error("catalog down")); + const executor = makeExecutor(); + const log = { warn: vi.fn() }; + expect((await executor.resolveModel("claude-opus-x", {}, null, log)).provider).toBe("anthropic"); + expect((await executor.resolveModel("gemini-3-x", {}, null, log)).provider).toBe("google"); + expect((await executor.resolveModel("grok-4-x", {}, null, log)).provider).toBe("x_ai"); + expect((await executor.resolveModel("gpt-5-x", {}, null, log)).provider).toBe("open_ai"); + }); +}); + +describe("completion payload shaping", () => { + it("sends wire provider values per model family", async () => { + resolveZedModels.mockImplementation(async () => catalogFor([ + ["claude-x", { provider: "anthropic" }], + ["gpt-x", { provider: "open_ai" }], + ["gemini-x", { provider: "google" }], + ["grok-x", { provider: "x_ai" }], + ])); + const captured = {}; + mockCatalogFetch(captured); + const executor = makeExecutor(); + + for (const [model, wire] of [ + ["claude-x", "anthropic"], + ["gpt-x", "open_ai"], + ["gemini-x", "google"], + ["grok-x", "x_ai"], + ]) { + await executor.execute({ model, body: { ...CHAT_BODY }, stream: false, credentials: {} }); + expect(captured.body.provider).toBe(wire); + expect(captured.body.model).toBe(model); + } + }); + + it("strips safetySettings on the Zed Gemini path only", async () => { + resolveZedModels.mockImplementation(async () => catalogFor([ + ["gemini-x", { provider: "google" }], + ["claude-x", { provider: "anthropic" }], + ])); + const captured = {}; + mockCatalogFetch(captured); + const executor = makeExecutor(); + + await executor.execute({ model: "gemini-x", body: { ...CHAT_BODY }, stream: false, credentials: {} }); + expect(captured.body.provider).toBe("google"); + expect(captured.body.provider_request).not.toHaveProperty("safetySettings"); + + // Sanity: the shared translator still emits safetySettings — the removal + // happens in the Zed executor, not in shared/native Gemini behavior. + const { openaiToGeminiRequest } = await import( + "open-sse/translator/request/openai-to-gemini.js" + ); + expect(openaiToGeminiRequest("gemini-x", { ...CHAT_BODY }, true)).toHaveProperty( + "safetySettings", + ); + }); +}); diff --git a/tests/unit/zed-live-models.test.js b/tests/unit/zed-live-models.test.js new file mode 100644 index 00000000..1b999044 --- /dev/null +++ b/tests/unit/zed-live-models.test.js @@ -0,0 +1,165 @@ +// Route-level acceptance for the Zed live-model wiring: +// GET /api/providers/[connectionId]/models → resolveZedModels → UI rows +// RUN WITH AN ISOLATED DB: DATA_DIR=$(mktemp -d) npx vitest run ... +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { GET } from "@/app/api/providers/[id]/models/route.js"; +import { createProviderConnection } from "@/models/index.js"; + +// Transport stub BELOW resolveZedModels: proxyAwareFetch captures the native +// fetch at import time, so stubbing globalThis.fetch cannot intercept it. +// Mock the module instead; untouched hosts pass through to native fetch. +const stub = vi.hoisted(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + return { mode: "ok", calls: [], nativeFetch }; +}); +vi.mock("open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: async (url, options) => { + const u = String(url); + stub.calls.push(u); + if (u.includes("cloud.zed.dev/client/users/me")) { + return Response.json({ default_organization_id: "org-1" }); + } + if (u.includes("cloud.zed.dev/client/llm_tokens")) { + return Response.json({ token: "llm-token" }); + } + if (u.includes("cloud.zed.dev/models")) { + if (stub.mode === "error") return new Response("boom", { status: 500 }); + if (stub.mode === "empty") return Response.json({ models: [] }); + return Response.json(stub.catalog); + } + return stub.nativeFetch(url, options); + }, + default: async (url, options) => stub.nativeFetch(url, options), +})); + +stub.catalog = { + models: [ + { + id: "claude-opus-4-live", + display_name: "Claude Opus Live", + provider: "anthropic", + max_token_count: 200000, + max_output_tokens: 32000, + supports_tools: true, + supports_images: true, + supports_thinking: true, + is_disabled: false, + }, + { + id: "gpt-live", + display_name: "GPT Live", + provider: "openai", + max_token_count: 128000, + max_output_tokens: 16384, + supports_tools: true, + is_disabled: false, + }, + { + id: "retired-model", + display_name: "Retired", + provider: "openai", + is_disabled: true, + }, + ], + default_model: "claude-opus-4-live", +}; + +beforeEach(() => { + stub.mode = "ok"; + stub.calls.length = 0; +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedZed(n) { + return createProviderConnection({ + provider: "zed", + authType: "oauth", + accessToken: `tok-live-${n}-${Date.now()}`, + email: `zed-live-${n}-${Date.now()}@example.com`, + providerSpecificData: { userId: `u-${n}`, systemId: `sys-${n}` }, + testStatus: "active", + }); +} + +async function getModels(connectionId) { + const req = new Request(`http://localhost/api/providers/${connectionId}/models`); + return GET(req, { params: Promise.resolve({ id: connectionId }) }); +} + +describe("criterion 1+2 — active connection + live catalog → models with metadata", () => { + it("returns enabled models with preserved metadata, no secrets", async () => { + const conn = await seedZed("m1"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models.map((m) => m.id).sort()).toEqual(["claude-opus-4-live", "gpt-live"]); + const opus = data.models.find((m) => m.id === "claude-opus-4-live"); + expect(opus.name).toBe("Claude Opus Live"); + expect(opus.contextLength).toBe(200000); + expect(opus.maxOutputTokens).toBe(32000); + expect(opus.supportsTools).toBe(true); + expect(opus.supportsImages).toBe(true); + expect(opus.supportsThinking).toBe(true); + // Credentials must never leak into the client response. + expect(JSON.stringify(data)).not.toContain(conn.accessToken); + expect(JSON.stringify(data)).not.toContain("tok-live"); + }); +}); + +describe("criterion 4 — disabled models excluded", () => { + it("is_disabled entries never reach the UI", async () => { + const conn = await seedZed("m2"); + const data = await (await getModels(conn.id)).json(); + expect(data.models.some((m) => m.id === "retired-model")).toBe(false); + }); +}); + +describe("criterion 4b — empty catalog → explicit warning", () => { + it("returns warning instead of silent zero", async () => { + stub.mode = "empty"; + const conn = await seedZed("m3"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models).toEqual([]); + expect(data.warning).toMatch(/no live models/i); + }); +}); + +describe("criterion 5 — resolver failure → useful warning, no crash", () => { + it("returns 200 with warning text", async () => { + stub.mode = "error"; + const conn = await seedZed("m4"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models).toEqual([]); + expect(data.warning).toMatch(/failed to fetch zed models/i); + }); +}); + +describe("criterion 6 (route) — unknown connection → 404", () => { + it("rejects missing connections", async () => { + const res = await getModels("00000000-0000-0000-0000-000000000000"); + expect(res.status).toBe(404); + }); +}); + +describe("criterion 5 (guard) — unsupported provider unchanged", () => { + it("still 400s for providers without a models config", async () => { + const conn = await createProviderConnection({ + provider: "kimchi-nope", + authType: "oauth", + accessToken: "x", + email: `guard-${Date.now()}@example.com`, + testStatus: "active", + }).catch(() => null); + // createProviderConnection may reject unknown providers; either way the + // route must not have gained a zed-shaped branch for others. + if (!conn) return; + const res = await getModels(conn.id); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/unit/zed-native-auth.test.js b/tests/unit/zed-native-auth.test.js new file mode 100644 index 00000000..66acc9cf --- /dev/null +++ b/tests/unit/zed-native-auth.test.js @@ -0,0 +1,266 @@ +// Acceptance suite for the Zed native-app auth fix. +// RUN WITH AN ISOLATED DB: DATA_DIR=$(mktemp -d) npx vitest run unit/zed-native-auth.test.js +// +// Covers criteria: +// 1. Zed proxy starts +// 2. Stray callback (no params) MUST NOT kill session / stop proxy +// 3. Real callback (user_id + access_token) MUST complete session + save connection +// 4. RSA decrypt works (round-trip) +// 5. systemId identical authorize → exchange → stored connection +// 6. register-session failure is distinguishable (backend contract) +// 8. (backend) reopen/re-register creates a fresh session +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import crypto from "node:crypto"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, +} from "open-sse/shared/zedAuth.js"; +import { + startZedProxy, + stopZedProxy, + registerZedSession, + getZedSessionStatus, + clearZedSession, +} from "@/lib/oauth/utils/server.js"; +import { + generateAuthData, + exchangeTokens, +} from "@/lib/oauth/providers/index.js"; + +const realFetch = globalThis.fetch; + +// Never hit the real network in tests: cloud.zed.dev calls are best-effort +// (postExchange try/catch) — fail them fast and loud instead. +beforeEach(() => { + globalThis.fetch = async (url, init) => { + if (String(url).includes("cloud.zed.dev")) { + return new Response("test-stubbed", { status: 500 }); + } + return realFetch(url, init); + }; +}); +afterEach(async () => { + globalThis.fetch = realFetch; + stopZedProxy(); + vi.restoreAllMocks(); +}); + +async function startTestProxy() { + const started = await startZedProxy(0); // random loopback port — parallel-safe + expect(started.success).toBe(true); + return started; +} + +/** Simulate zed.dev: RSA-encrypt a plaintext token with the flow's public key. */ +function encryptForCallback(publicKeyB64Url, plaintext) { + const der = Buffer.from(String(publicKeyB64Url), "base64url"); + const key = crypto.createPublicKey({ key: der, format: "der", type: "pkcs1" }); + return crypto + .publicEncrypt( + { key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + Buffer.from(plaintext, "utf8"), + ) + .toString("base64url"); +} + +describe("criterion 1 — Zed proxy starts", () => { + it("binds 127.0.0.1 and reports a usable callback URL", async () => { + const started = await startTestProxy(); + expect(started.port).toBeGreaterThan(0); + expect(started.callbackUrl).toBe(`http://127.0.0.1:${started.port}/`); + }); +}); + +describe("criterion 4 — RSA decrypt works", () => { + it("round-trips OAEP-SHA256 through the verifier slot", async () => { + const auth = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const encrypted = encryptForCallback(auth.publicKey, "plaintext-token-abc"); + expect(decryptZedAccessToken(encrypted, auth.privateKeyVerifier)).toBe( + "plaintext-token-abc", + ); + }); + + it("rejects a missing verifier instead of silently failing", () => { + const auth = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const encrypted = encryptForCallback(auth.publicKey, "x"); + expect(() => decryptZedAccessToken(encrypted, null)).toThrow( + /private key verifier/i, + ); + }); + + it("parser keeps strict validation (no weakened acceptance)", () => { + expect(() => parseZedCallbackPayload("")).toThrow(); + expect(() => parseZedCallbackPayload("http://127.0.0.1:1/")).toThrow( + /user_id and access_token/, + ); + expect(() => + parseZedCallbackPayload("http://127.0.0.1:1/?user_id=only-user"), + ).toThrow(/user_id and access_token/); + }); +}); + +describe("criterion 2 — stray callback MUST NOT kill session", () => { + it("bare GET / leaves session pending and proxy listening", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + expect( + registerZedSession({ state: "stray-state-1", codeVerifier: auth.privateKeyVerifier }), + ).toBe(true); + + const res = await realFetch(`http://127.0.0.1:${started.port}/`); + expect(res.status).toBe(200); + + // Session must still be pending (not poisoned to error)… + const session = getZedSessionStatus("stray-state-1"); + expect(session).not.toBeNull(); + expect(session.status).toBe("pending"); + + // …and the SAME server must still own the port (no silent restart). + const again = await startZedProxy(0); + expect(again.port).toBe(started.port); + + clearZedSession("stray-state-1"); + }); + + it("GET /callback with unrelated params leaves session pending", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + registerZedSession({ state: "stray-state-2", codeVerifier: auth.privateKeyVerifier }); + + const res = await realFetch(`http://127.0.0.1:${started.port}/callback?foo=bar`); + expect(res.status).toBe(200); + + const session = getZedSessionStatus("stray-state-2"); + expect(session).not.toBeNull(); + expect(session.status).toBe("pending"); + clearZedSession("stray-state-2"); + }); +}); + +describe("criterion 3 — real callback completes session + saves connection", () => { + it("user_id + access_token → done, decrypted token persisted", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const state = `real-state-${Date.now()}`; + registerZedSession({ state, codeVerifier: auth.privateKeyVerifier, systemId: auth.systemId }); + + const encrypted = encryptForCallback(auth.publicKey, "decrypted-token-xyz"); + const cb = new URL(`http://127.0.0.1:${started.port}/`); + cb.searchParams.set("user_id", "user-123"); + cb.searchParams.set("access_token", encrypted); + const res = await realFetch(cb.toString()); + expect(res.status).toBe(200); + + const session = getZedSessionStatus(state); + expect(session).not.toBeNull(); + expect(session.status).toBe("done"); + expect(session.connectionId).toBeTruthy(); + + const { getProviderConnectionById } = await import("@/models/index.js"); + const conn = await getProviderConnectionById(session.connectionId); + expect(conn).toBeTruthy(); + expect(conn.provider).toBe("zed"); + expect(conn.accessToken).toBe("decrypted-token-xyz"); + expect(conn.providerSpecificData?.userId).toBe("user-123"); + expect(conn.providerSpecificData?.systemId).toBe(auth.systemId); + + // Proxy stopped itself after the terminal outcome (no orphan listener). + const again = await startZedProxy(0); + expect(again.port).not.toBe(started.port); + stopZedProxy(); + }); +}); + +describe("criterion 5 — systemId stable authorize → exchange → stored", () => { + it("generateAuthData exposes the systemId sent to zed.dev", async () => { + const auth = await generateAuthData("zed", "http://127.0.0.1:59999/", { + nativeAppPort: 59999, + }); + const url = new URL(auth.authUrl); + expect(url.searchParams.get("native_app_port")).toBe("59999"); + // The system_id embedded in the sign-in URL must be observable downstream. + expect(auth.systemId).toBe(url.searchParams.get("system_id")); + expect(auth.systemId).toBeTruthy(); + }); + + it("exchange preserves the registered systemId (no regeneration)", async () => { + const auth = await generateAuthData("zed", "http://127.0.0.1:59998/", { + nativeAppPort: 59998, + }); + // Public key always rides in the authorize URL (mirrors the real flow). + const pubFromUrl = new URL(auth.authUrl).searchParams.get("native_app_public_key"); + expect(pubFromUrl).toBeTruthy(); + const enc2 = encryptForCallback(pubFromUrl, "tok2"); + const tokens = await exchangeTokens( + "zed", + `/?user_id=u1&access_token=${encodeURIComponent(enc2)}`, + null, + auth.codeVerifier, + auth.state, + { systemId: auth.systemId }, + ); + expect(tokens.providerSpecificData.systemId).toBe(auth.systemId); + }); +}); + +describe("criterion 6 — register-session failure is distinguishable", () => { + it("route reports { success: false } when the verifier is missing", async () => { + const { POST } = await import("@/app/api/oauth/[provider]/[action]/route.js"); + const req = new Request("http://localhost/api/oauth/zed/register-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "no-verifier-state" }), + }); + const res = await POST(req, { + params: Promise.resolve({ provider: "zed", action: "register-session" }), + }); + const data = await res.json(); + // Backend contract: failure must be explicit (modal is required to check it). + expect(data.success).toBe(false); + }); +}); + +describe("criterion 8 (backend) — re-register creates a fresh session", () => { + it("a new register supersedes the old state cleanly", async () => { + const a = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const b = createZedNativeAuthData({}, { nativeAppPort: 1 }); + registerZedSession({ state: "old-state", codeVerifier: a.privateKeyVerifier }); + registerZedSession({ state: "new-state", codeVerifier: b.privateKeyVerifier }); + + expect(getZedSessionStatus("old-state")).toBeNull(); + const fresh = getZedSessionStatus("new-state"); + expect(fresh).not.toBeNull(); + expect(fresh.status).toBe("pending"); + expect(fresh.codeVerifier).toBe(b.privateKeyVerifier); + clearZedSession("new-state"); + }); +}); + +describe("criterion L — decrypt failure errors the session but keeps the server", () => { + it("wrong-key token → session error, listener survives for the live attempt", async () => { + const started = await startTestProxy(); + const live = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const other = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const state = `wrongkey-state-${Date.now()}`; + registerZedSession({ state, codeVerifier: live.privateKeyVerifier }); + + // Token encrypted for a DIFFERENT keypair (e.g. superseded popup). + const bad = encryptForCallback(other.publicKey, "not-for-this-key"); + const cb = new URL(`http://127.0.0.1:${started.port}/`); + cb.searchParams.set("user_id", "user-123"); + cb.searchParams.set("access_token", bad); + const res = await realFetch(cb.toString()); + expect(res.status).toBe(200); + + const session = getZedSessionStatus(state); + expect(session).not.toBeNull(); + expect(session.status).toBe("error"); + expect(session.error).toMatch(/decrypt/i); + + // Server must still be alive (same port) for the live attempt. + const again = await startZedProxy(0); + expect(again.port).toBe(started.port); + clearZedSession(state); + }); +});