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:
@@ -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);
|
||||
|
||||
@@ -4,7 +4,6 @@ export default {
|
||||
priority: 10,
|
||||
alias: "zd",
|
||||
uiAlias: "zd",
|
||||
hidden: true,
|
||||
display: {
|
||||
name: "Zed",
|
||||
icon: "code",
|
||||
|
||||
@@ -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("<22>")) 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
121
tests/unit/zed-completions-wire.test.js
Normal file
121
tests/unit/zed-completions-wire.test.js
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
165
tests/unit/zed-live-models.test.js
Normal file
165
tests/unit/zed-live-models.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
266
tests/unit/zed-native-auth.test.js
Normal file
266
tests/unit/zed-native-auth.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user