feat(oauth): complete Windsurf / Devin CLI OAuth + API-token flows (#2168)

Integrated into release/v3.8.0 — complete Windsurf/Devin CLI OAuth + API-token executor flows with unit tests.
This commit is contained in:
Aleksandr
2026-05-12 03:49:32 +03:00
committed by GitHub
parent 95944dad92
commit ff730b372e
17 changed files with 2409 additions and 168 deletions

View File

@@ -21,6 +21,7 @@ import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import {
jsonObjectSchema,
oauthExchangeSchema,
oauthImportTokenSchema,
oauthPollSchema,
} from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -30,6 +31,16 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
if (!globalThis.__codexCallbackState) {
globalThis.__codexCallbackState = null;
}
// Windsurf / Devin CLI PKCE callback server state (separate from Codex)
if (!globalThis.__windsurfCallbackState) {
globalThis.__windsurfCallbackState = null;
}
/** Providers that use the PKCE browser callback flow (like Codex). */
const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
/** Providers that allow direct import of a raw API token (no OAuth exchange). */
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli"]);
/**
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).
@@ -150,40 +161,44 @@ export async function GET(
}
/**
* Start Codex callback server on port 1455
* Returns the auth URL and stores codeVerifier for later exchange
* Start PKCE callback server for Codex, Windsurf, or Devin CLI.
* Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0).
* Returns the auth URL and stores codeVerifier for later exchange.
*/
async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) {
if (provider !== "codex") {
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
return NextResponse.json(
{ error: "Callback server only supported for codex" },
{ error: `Callback server not supported for provider: ${provider}` },
{ status: 400 }
);
}
const isWindsurf = provider === "windsurf" || provider === "devin-cli";
const stateKey = isWindsurf ? "__windsurfCallbackState" : "__codexCallbackState";
// Clean up existing server if any
if (globalThis.__codexCallbackState?.close) {
if (globalThis[stateKey]?.close) {
try {
globalThis.__codexCallbackState.close();
globalThis[stateKey].close();
} catch (e) {
/* ignore */
}
}
globalThis.__codexCallbackState = null;
globalThis[stateKey] = null;
try {
// Start temp server on port 1455
// Codex: fixed port 1455. Windsurf/Devin CLI: OS-assigned random port (0)
const serverPort = isWindsurf ? 0 : 1455;
const { port, close } = await startLocalServer((params) => {
// Write directly to globalThis so it survives module reloads
if (globalThis.__codexCallbackState) {
globalThis.__codexCallbackState.callbackParams = params;
if (globalThis[stateKey]) {
globalThis[stateKey].callbackParams = params;
}
}, 1455);
}, serverPort);
const redirectUri = `http://localhost:${port}/auth/callback`;
const authData = generateAuthData(provider, redirectUri);
globalThis.__codexCallbackState = {
globalThis[stateKey] = {
callbackParams: null,
close,
port,
@@ -195,13 +210,13 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
// Auto-cleanup after 5 minutes
const startedAt = Date.now();
setTimeout(() => {
if (globalThis.__codexCallbackState?.startedAt === startedAt) {
if (globalThis[stateKey]?.startedAt === startedAt) {
try {
close();
} catch (e) {
/* ignore */
}
globalThis.__codexCallbackState = null;
globalThis[stateKey] = null;
}
}, 300000);
@@ -263,6 +278,12 @@ export async function POST(
return NextResponse.json({ error: validation.error }, { status: 400 });
}
body = validation.data;
} else if (action === "import-token") {
const validation = validateBody(oauthImportTokenSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
body = validation.data;
}
if (action === "exchange") {
@@ -454,15 +475,20 @@ export async function POST(
if (action === "poll-callback") {
const { connectionId } = body;
// Poll for Codex callback server result
if (provider !== "codex") {
// poll-callback is supported for all PKCE callback providers
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
return NextResponse.json(
{ error: "poll-callback only supported for codex" },
{
error: `poll-callback only supported for PKCE callback providers: ${[...PKCE_CALLBACK_PROVIDERS].join(", ")}`,
},
{ status: 400 }
);
}
if (!globalThis.__codexCallbackState) {
// Windsurf and Devin CLI share __windsurfCallbackState; Codex uses its own slot
const stateKey = provider === "codex" ? "__codexCallbackState" : "__windsurfCallbackState";
if (!globalThis[stateKey]) {
return NextResponse.json({
success: false,
error: "no_server",
@@ -470,13 +496,13 @@ export async function POST(
});
}
if (!globalThis.__codexCallbackState.callbackParams) {
if (!globalThis[stateKey].callbackParams) {
return NextResponse.json({ success: false, pending: true });
}
// Callback received! Extract code and exchange for tokens
const params = globalThis.__codexCallbackState.callbackParams;
const { redirectUri, codeVerifier, close } = globalThis.__codexCallbackState;
const params = globalThis[stateKey].callbackParams;
const { redirectUri, codeVerifier, close } = globalThis[stateKey];
// Clean up server
try {
@@ -484,7 +510,7 @@ export async function POST(
} catch (e) {
/* ignore */
}
globalThis.__codexCallbackState = null;
globalThis[stateKey] = null;
if (params.error) {
return NextResponse.json({
@@ -571,6 +597,76 @@ export async function POST(
}
}
if (action === "import-token") {
const { token, connectionId } = body;
if (!IMPORT_TOKEN_PROVIDERS.has(provider)) {
return NextResponse.json(
{
error: `import-token not supported for provider: ${provider}. Supported: ${[...IMPORT_TOKEN_PROVIDERS].join(", ")}`,
},
{ status: 400 }
);
}
try {
// Map the raw token via the provider's mapTokens() — skips the HTTP exchange entirely.
const providerData = getProvider(provider);
const tokenData = providerData.mapTokens({ accessToken: token });
// Normalize: if name is missing, use email as fallback display label
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
tokenData.name = tokenData.email || tokenData.displayName;
}
const expiresAt = tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
: null;
let connection: any;
if (tokenData.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => {
if (c.id && safeEqual(connectionId, c.id)) return true;
if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false;
return true;
});
const matchId = typeof match?.id === "string" ? match.id : null;
if (matchId) {
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
isActive: true,
});
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt,
testStatus: "active",
});
}
await syncToCloudIfEnabled();
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (importErr: any) {
return NextResponse.json({ success: false, error: importErr.message }, { status: 500 });
}
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.log("OAuth POST error:", error);

View File

@@ -0,0 +1,13 @@
/**
* /auth/callback — OAuth callback endpoint for providers that use the
* `/auth/callback` path (Windsurf, Devin CLI PKCE flow).
*
* Reuses the same logic as /callback:
* - postMessage to opener (popup mode)
* - BroadcastChannel (same-origin tabs)
* - localStorage fallback
*
* On true localhost the random-port callback server intercepts this path first,
* so this page is only reached in the LAN / popup-without-callback-server case.
*/
export { default } from "@/app/callback/page";

View File

@@ -264,6 +264,44 @@ export const CURSOR_CONFIG = {
},
};
// Windsurf / Devin CLI Configuration
//
// Authentication uses PKCE Authorization Code Flow — same pattern as Codex CLI.
// Extracted from Devin CLI binary (model_configs_v2.bin + devin.exe strings):
//
// Authorize URL: https://app.devin.ai/editor/signin
// Params: response_type=code, redirect_uri, code_challenge, code_challenge_method=S256
// Callback path: /auth/callback (local server on random port 127.0.0.1:0)
// Exchange: POST https://server.codeium.com/<ExchangePKCEAuthorizationCode>
// via Connect JSON protocol (Content-Type: application/json)
// Response field: windsurfApiKey → stored as accessToken / WINDSURF_API_KEY
//
// Fallback: user can also paste a token from windsurf.com/show-auth-token
export const WINDSURF_CONFIG = {
// Browser-based PKCE authorize endpoint (extracted from devin.exe binary)
authorizeUrl: "https://app.devin.ai/editor/signin",
codeChallengeMethod: "S256" as const,
// Local callback server — 0 = OS assigns a free port
callbackPort: 0,
callbackPath: "/auth/callback",
// Token exchange via Windsurf Connect JSON (gRPC-web + JSON)
apiServerUrl: "https://server.codeium.com",
exchangePath: "/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode",
// Inference server URL (gRPC-web requests go here)
inferenceUrl: "https://server.self-serve.windsurf.com",
// Fallback: user visits this page, copies token, pastes it
showAuthTokenUrl: "https://windsurf.com/show-auth-token",
// Token refresh via Firebase Secure Token Service (for short-lived browser-flow tokens).
// Value comes from WINDSURF_FIREBASE_API_KEY env var (set in .env.example).
// Long-lived import tokens never need this — refresh is skipped when key is absent.
firebaseApiKey: process.env.WINDSURF_FIREBASE_API_KEY || "",
firebaseTokenUrl: "https://securetoken.googleapis.com/v1/token",
// IDE identity sent with every gRPC request
ideName: "windsurf",
ideVersion: "3.14.0",
extensionVersion: "3.14.0",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -284,4 +322,6 @@ export const PROVIDERS = {
CURSOR: "cursor",
KILOCODE: "kilocode",
CLINE: "cline",
WINDSURF: "windsurf",
DEVIN_CLI: "devin-cli",
};

View File

@@ -23,6 +23,7 @@ import { kiro } from "./kiro";
import { cursor } from "./cursor";
import { kilocode } from "./kilocode";
import { cline } from "./cline";
import { windsurf } from "./windsurf";
export const PROVIDERS = {
claude,
@@ -39,6 +40,9 @@ export const PROVIDERS = {
cursor,
kilocode,
cline,
windsurf,
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
"devin-cli": windsurf,
};
export default PROVIDERS;

View File

@@ -0,0 +1,121 @@
import { WINDSURF_CONFIG } from "../constants/oauth";
/**
* Windsurf / Devin CLI OAuth Provider
*
* Uses PKCE Authorization Code Flow — same pattern as Codex CLI.
* Extracted from Devin CLI binary (devin.exe string analysis):
*
* 1. OmniRoute starts a local callback server (random port, 127.0.0.1)
* 2. Browser opens:
* https://app.devin.ai/editor/signin
* ?response_type=code
* &redirect_uri=http://127.0.0.1:PORT/auth/callback
* &code_challenge=<S256_CHALLENGE>
* &code_challenge_method=S256
* 3. User logs in (Google / GitHub / Windsurf Enterprise)
* 4. Browser redirects back to callback server with `code`
* 5. Exchange code via Windsurf Connect JSON:
* POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode
* { "code": "...", "codeVerifier": "...", "redirectUri": "..." }
* 6. Response: { "windsurfApiKey": "...", "apiServerUrl": "...", ... }
* 7. `windsurfApiKey` stored as `accessToken` (= WINDSURF_API_KEY)
*
* Fallback (import_token): user visits windsurf.com/show-auth-token,
* copies their API key, and pastes it into the connection form.
*/
export const windsurf = {
config: WINDSURF_CONFIG,
flowType: "authorization_code_pkce",
// Fixed callback path expected by Devin CLI auth flow
callbackPath: WINDSURF_CONFIG.callbackPath,
// Port 0 = OS assigns a free port (we use the globalThis devin callback state)
callbackPort: WINDSURF_CONFIG.callbackPort,
buildAuthUrl: (
config: typeof WINDSURF_CONFIG,
redirectUri: string,
state: string,
codeChallenge: string
) => {
const params = new URLSearchParams({
response_type: "code",
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
/**
* Exchange authorization code for Windsurf API key.
* Uses the Windsurf Connect JSON protocol (not standard OAuth token endpoint).
*/
exchangeToken: async (
config: typeof WINDSURF_CONFIG,
code: string,
redirectUri: string,
codeVerifier: string
) => {
const url = `${config.apiServerUrl}${config.exchangePath}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
// Connect protocol version header
"Connect-Protocol-Version": "1",
},
body: JSON.stringify({
code,
codeVerifier,
redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Windsurf token exchange failed (${response.status}): ${error}`);
}
const data = await response.json();
return data;
},
/**
* Map exchange response to OmniRoute connection fields.
* The Windsurf Connect response uses camelCase JSON:
* windsurfApiKey, apiServerUrl, devinWebappHost, devinApiUrl
*/
mapTokens: (tokens: {
windsurfApiKey?: string;
apiServerUrl?: string;
devinWebappHost?: string;
devinApiUrl?: string;
// Fallback import-token fields
accessToken?: string;
apiKey?: string;
refreshToken?: string;
expiresIn?: number;
email?: string;
authMethod?: string;
}) => {
// PKCE flow: token is in windsurfApiKey
const token = tokens.windsurfApiKey || tokens.accessToken || tokens.apiKey || "";
return {
accessToken: token,
// Windsurf API keys are long-lived — no refresh token needed
refreshToken: tokens.refreshToken || null,
expiresIn: tokens.expiresIn || 0,
email: tokens.email || null,
providerSpecificData: {
authMethod: tokens.authMethod || (tokens.windsurfApiKey ? "browser" : "import"),
apiServerUrl: tokens.apiServerUrl || null,
devinWebappHost: tokens.devinWebappHost || null,
devinApiUrl: tokens.devinApiUrl || null,
},
};
},
};

View File

@@ -9,6 +9,9 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
/** Providers that use a local callback server on a random port (PKCE browser flow). */
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
type OAuthModalProps = {
isOpen: boolean;
provider?: string;
@@ -41,6 +44,12 @@ export default function OAuthModal({
const [isDeviceCode, setIsDeviceCode] = useState(false);
const [deviceData, setDeviceData] = useState(null);
const [polling, setPolling] = useState(false);
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
const [showPasteToken, setShowPasteToken] = useState(false);
const [pasteToken, setPasteToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli";
const popupRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
const deviceVerificationUrl =
@@ -149,6 +158,42 @@ export default function OAuthModal({
[authData, provider, onSuccess, reauthConnection]
);
// Save a raw API token directly (windsurf / devin-cli import-token path)
const handleSaveToken = useCallback(async () => {
const token = pasteToken.trim();
if (!token || !provider) return;
setSavingToken(true);
setError(null);
try {
// POST to /exchange with a synthetic "import_token" payload.
// The windsurf provider's mapTokens() handles a bare accessToken/apiKey field.
const res = await fetch(`/api/oauth/${provider}/import-token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
connectionId: reauthConnection?.id,
}),
});
const data = await res.json();
if (!res.ok) {
const errMsg =
typeof data.error === "object" && data.error !== null
? ((data.error as Record<string, unknown>).message as string) ||
JSON.stringify(data.error)
: data.error || "Save failed";
throw new Error(errMsg);
}
setStep("success");
onSuccess?.();
} catch (err) {
// Show error inline inside the paste-token form (don't flip to error step)
setError(err.message);
} finally {
setSavingToken(false);
}
}, [pasteToken, provider, onSuccess, reauthConnection]);
// Poll for device code token
const startPolling = useCallback(
async (deviceCode, codeVerifier, interval, extraData) => {
@@ -273,13 +318,14 @@ export default function OAuthModal({
forceManual = true;
}
// Codex: on localhost use callback server on port 1455,
// on remote use standard auth code flow (callback server is unreachable)
if (provider === "codex") {
if (isLocalhost) {
// Localhost: use callback server on port 1455 + polling
// PKCE callback server providers (Codex, Windsurf, Devin CLI):
// On localhost, spin up a local callback server and poll for the result.
// Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port.
// On remote the server is unreachable — fall through to standard manual flow.
if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) {
if (isTrueLocalhost) {
try {
const serverRes = await fetch(`/api/oauth/codex/start-callback-server`);
const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`);
const serverData = await serverRes.json();
if (!serverRes.ok) throw new Error(serverData.error);
@@ -297,7 +343,7 @@ export default function OAuthModal({
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 2000));
const pollRes = await fetch(`/api/oauth/codex/poll-callback`, {
const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId: reauthConnection?.id }),
@@ -318,10 +364,10 @@ export default function OAuthModal({
setPolling(false);
throw new Error("Authorization timeout");
} catch (codexErr) {
} catch (pkceErr) {
console.warn(
"Codex callback server failed, falling back to standard manual flow",
codexErr
`${provider} callback server failed, falling back to manual flow`,
pkceErr
);
setPolling(false);
forceManual = true;
@@ -333,6 +379,8 @@ export default function OAuthModal({
// Authorization code flow
// Redirect URI strategy:
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
// - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback
// (on true localhost the callback server handles it; this is only reached on remote)
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
// the built-in credentials. Remote users must configure their own credentials.
@@ -341,6 +389,11 @@ export default function OAuthModal({
let redirectUri: string;
if (provider === "codex" || provider === "openai") {
redirectUri = "http://localhost:1455/auth/callback";
} else if (provider === "windsurf" || provider === "devin-cli") {
// Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects.
// On true localhost this code is never reached (callback server handles the flow above).
const port = window.location.port || "20128";
redirectUri = `http://localhost:${port}/auth/callback`;
} else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) {
// Google OAuth built-in credentials only accept localhost redirect URIs.
// Even in remote deployments we use localhost — user copies the callback URL manually.
@@ -618,148 +671,210 @@ export default function OAuthModal({
size="lg"
>
<div className="flex flex-col gap-4">
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
<Button variant="ghost" onClick={() => setStep("input")}>
{t("popupBlocked")}
</Button>
{/* Paste-token tab toggle (Windsurf / Devin CLI only) */}
{supportsTokenPaste && step !== "success" && (
<div className="flex gap-2 border-b border-border pb-3">
<button
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setShowPasteToken(false)}
>
Browser Login
</button>
<button
className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setShowPasteToken(true)}
>
Paste API Key
</button>
</div>
)}
{/* Device Code Flow - Waiting */}
{step === "waiting" && isDeviceCode && deviceData && (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
<Button
size="sm"
variant="ghost"
icon={copied === "verify_url" ? "check" : "content_copy"}
onClick={() => copy(deviceVerificationUrl, "verify_url")}
/>
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">
{deviceData.user_code}
</p>
<Button
size="sm"
variant="ghost"
icon={copied === "user_code" ? "check" : "content_copy"}
onClick={() => copy(deviceData.user_code, "user_code")}
/>
</div>
</div>
{/* Paste-token form (Windsurf / Devin CLI) */}
{supportsTokenPaste && showPasteToken && step !== "success" && (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">
{provider === "windsurf"
? "Visit windsurf.com/show-auth-token, copy your Windsurf API key, and paste it below."
: "Provide your WINDSURF_API_KEY (obtained via `devin auth login` or windsurf.com/show-auth-token)."}
</p>
<Input
value={pasteToken}
onChange={(e) => setPasteToken(e.target.value)}
placeholder="ws-..."
type="password"
label="API Key / Token"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<Button
onClick={handleSaveToken}
fullWidth
disabled={!pasteToken.trim() || savingToken}
>
{savingToken ? "Saving…" : "Save Connection"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
{t("deviceCodeWaiting")}
</div>
)}
{/* OAuth flow steps — hidden when paste-token mode is active */}
{(!supportsTokenPaste || !showPasteToken) && (
<>
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
<Button variant="ghost" onClick={() => setStep("input")}>
{t("popupBlocked")}
</Button>
</div>
)}
{/* Device Code Flow - Waiting */}
{step === "waiting" && isDeviceCode && deviceData && (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
<Button
size="sm"
variant="ghost"
icon={copied === "verify_url" ? "check" : "content_copy"}
onClick={() => copy(deviceVerificationUrl, "verify_url")}
/>
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">
{deviceData.user_code}
</p>
<Button
size="sm"
variant="ghost"
icon={copied === "user_code" ? "check" : "content_copy"}
onClick={() => copy(deviceData.user_code, "user_code")}
/>
</div>
</div>
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">
progress_activity
</span>
{t("deviceCodeWaiting")}
</div>
)}
</>
)}
{/* Manual Input Step */}
{step === "input" && !isDeviceCode && (
<>
<div className="space-y-4">
{/* Remote/LAN server info for Google OAuth providers */}
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>
{t.rich("googleOAuthWarning", {
code: (c) => <code className="font-mono">{c}</code>,
a: (c) => (
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
{c}
</a>
),
})}
</strong>
</div>
)}
{/* Generic remote info for other providers */}
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
</span>
{t("remoteAccessInfo")}
</div>
)}
<div>
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
<div className="flex gap-2">
<Input
value={authData?.authUrl || ""}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authData?.authUrl, "auth_url")}
>
{t("copy")}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
<p className="text-xs text-text-muted mb-2">
{t.rich("step2Hint", {
code: (c) => <code className="font-mono">{c}</code>,
})}
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: placeholderUrl
}
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleManualSubmit}
fullWidth
disabled={!callbackUrl || !authData}
>
{t("connect")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
)}
</>
)}
{/* Manual Input Step */}
{step === "input" && !isDeviceCode && (
<>
<div className="space-y-4">
{/* Remote/LAN server info for Google OAuth providers */}
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>
{t.rich("googleOAuthWarning", {
code: (c) => <code className="font-mono">{c}</code>,
a: (c) => (
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
{c}
</a>
),
})}
</strong>
</div>
)}
{/* Generic remote info for other providers */}
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
{t("remoteAccessInfo")}
</div>
)}
<div>
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
<div className="flex gap-2">
<Input
value={authData?.authUrl || ""}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authData?.authUrl, "auth_url")}
>
{t("copy")}
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
<p className="text-xs text-text-muted mb-2">
{t.rich("step2Hint", {
code: (c) => <code className="font-mono">{c}</code>,
})}
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: placeholderUrl
}
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl || !authData}>
{t("connect")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
)}
{/* Success Step */}
{/* Success Step — shown for both OAuth and paste-token flows */}
{step === "success" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
@@ -777,8 +892,8 @@ export default function OAuthModal({
</div>
)}
{/* Error Step */}
{step === "error" && (
{/* Error Step — OAuth errors only; paste-token errors shown inline */}
{step === "error" && !showPasteToken && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>

View File

@@ -90,6 +90,28 @@ export const OAUTH_PROVIDERS = {
color: "#5B9BD5",
textIcon: "CL",
},
windsurf: {
id: "windsurf",
alias: "ws",
name: "Windsurf (Devin CLI)",
icon: "air",
color: "#00C5A0",
textIcon: "WS",
authHint:
"Sign in at windsurf.com to get your token. Visit windsurf.com/show-auth-token after logging in and paste it here, or use the device-code login flow.",
website: "https://windsurf.com",
},
"devin-cli": {
id: "devin-cli",
alias: "dv",
name: "Devin CLI (Official)",
icon: "terminal",
color: "#6366F1",
textIcon: "DV",
authHint:
"Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai",
website: "https://cli.devin.ai",
},
};
// Web / Cookie Providers

View File

@@ -67,6 +67,24 @@ const CLI_TOOLS: Record<string, any> = {
healthcheckTimeoutMs: 4000,
paths: {},
},
devin: {
defaultCommand: "devin",
envBinKey: "CLI_DEVIN_BIN",
requiresBinary: true,
// devin acp cold-start can take a few seconds on first run
healthcheckTimeoutMs: 12000,
paths: {
// %APPDATA%\devin\config.json (Windows)
// ~/.config/devin/config.json (Linux/macOS)
config: isWindows()
? path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"devin",
"config.json"
)
: path.join(os.homedir(), ".config", "devin", "config.json"),
},
},
cline: {
defaultCommand: "cline",
envBinKey: "CLI_CLINE_BIN",
@@ -439,6 +457,10 @@ const getKnownToolPaths = (toolId: string): string[] => {
["qodercli.cmd", "qodercli"],
["qodercli.exe", "qodercli"],
],
devin: [
["devin.exe", "devin"],
["devin.cmd", "devin"],
],
};
const bins = toolBins[toolId] || [];
@@ -464,6 +486,11 @@ const getKnownToolPaths = (toolId: string): string[] => {
paths.push(path.join(home, "bin", "droid.exe"));
}
// Devin CLI installs to %LOCALAPPDATA%\devin\cli\bin\devin.exe
if (toolId === "devin" && localAppData) {
paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe"));
}
for (const [winName] of bins) {
if (npmPrefix) paths.push(path.join(npmPrefix, winName));
if (appData) {
@@ -501,6 +528,12 @@ const getKnownToolPaths = (toolId: string): string[] => {
if (toolId === "claude") {
paths.push(path.join(home, ".claude", "bin", posixName));
}
// Devin CLI installs to ~/.local/share/devin/bin/devin (Linux)
// or via shell installer to ~/.devin/bin/devin
if (toolId === "devin") {
paths.push(path.join(home, ".local", "share", "devin", "bin", "devin"));
paths.push(path.join(home, ".devin", "bin", "devin"));
}
}
}

View File

@@ -1269,6 +1269,12 @@ export const oauthPollSchema = z.object({
extraData: z.unknown().optional(),
});
/** Import a raw API token (e.g. WINDSURF_API_KEY) without going through the browser OAuth flow. */
export const oauthImportTokenSchema = z.object({
token: z.string().trim().min(1, "Token is required"),
connectionId: z.string().optional(),
});
export const cursorImportSchema = z.object({
accessToken: z.string().trim().min(1, "Access token is required"),
machineId: z.string().trim().optional(),