Merge branch 'pr-50'

This commit is contained in:
decolua
2026-02-05 11:02:50 +07:00
16 changed files with 1776 additions and 2 deletions
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -495,6 +495,12 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "cursor" ? (
<CursorAuthModal
isOpen={showOAuthModal}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { CursorService } from "@/lib/oauth/services/cursor";
import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
/**
* POST /api/oauth/cursor/import
* Import and validate access token from Cursor IDE's local SQLite database
*
* Request body:
* - accessToken: string - Access token from cursorAuth/accessToken
* - machineId: string - Machine ID from storage.serviceMachineId
*/
export async function POST(request) {
try {
const { accessToken, machineId } = await request.json();
if (!accessToken || typeof accessToken !== "string") {
return NextResponse.json(
{ error: "Access token is required" },
{ status: 400 }
);
}
if (!machineId || typeof machineId !== "string") {
return NextResponse.json(
{ error: "Machine ID is required" },
{ status: 400 }
);
}
const cursorService = new CursorService();
// Validate token by making API call
const tokenData = await cursorService.validateImportToken(
accessToken.trim(),
machineId.trim()
);
// Try to extract user info from token
const userInfo = cursorService.extractUserInfo(tokenData.accessToken);
// Save to database
const connection = await createProviderConnection({
provider: "cursor",
authType: "oauth",
accessToken: tokenData.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
email: userInfo?.email || null,
providerSpecificData: {
machineId: tokenData.machineId,
authMethod: "imported",
provider: "Imported",
userId: userInfo?.userId,
},
testStatus: "active",
});
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
},
});
} catch (error) {
console.log("Cursor import token error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* GET /api/oauth/cursor/import
* Get instructions for importing Cursor token
*/
export async function GET() {
const cursorService = new CursorService();
const instructions = cursorService.getTokenStorageInstructions();
return NextResponse.json({
provider: "cursor",
method: "import_token",
instructions,
requiredFields: [
{
name: "accessToken",
label: "Access Token",
description: "From cursorAuth/accessToken in state.vscdb",
type: "textarea",
},
{
name: "machineId",
label: "Machine ID",
description: "From storage.serviceMachineId in state.vscdb",
type: "text",
},
],
});
}
/**
* Sync to Cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud after Cursor import:", error);
}
}
+29
View File
@@ -142,6 +142,34 @@ export const KIRO_CONFIG = {
authMethods: ["builder-id", "idc", "google", "github", "import"],
};
// Cursor OAuth Configuration (Import Token from Cursor IDE)
// Cursor stores credentials in SQLite database: state.vscdb
// Keys: cursorAuth/accessToken, storage.serviceMachineId
export const CURSOR_CONFIG = {
// API endpoints
apiEndpoint: "https://api2.cursor.sh",
chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
// Additional endpoints
api3Endpoint: "https://api3.cursor.sh", // Telemetry
agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode
// Client metadata
clientVersion: "0.48.6",
clientType: "ide",
// Token storage locations (for user reference)
tokenStoragePaths: {
linux: "~/.config/Cursor/User/globalStorage/state.vscdb",
macos: "/Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb",
windows: "%APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb",
},
// Database keys
dbKeys: {
accessToken: "cursorAuth/accessToken",
machineId: "storage.serviceMachineId",
},
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -156,4 +184,5 @@ export const PROVIDERS = {
OPENAI: "openai",
GITHUB: "github",
KIRO: "kiro",
CURSOR: "cursor",
};
+17
View File
@@ -13,6 +13,7 @@ import {
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
KIRO_CONFIG,
CURSOR_CONFIG,
} from "./constants/oauth";
// Provider configurations
@@ -656,6 +657,22 @@ const PROVIDERS = {
},
}),
},
cursor: {
config: CURSOR_CONFIG,
flowType: "import_token",
// Cursor uses import token flow - tokens are extracted from local SQLite database
// No OAuth flow needed, handled by /api/oauth/cursor/import route
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
},
};
/**
+179
View File
@@ -0,0 +1,179 @@
import { CURSOR_CONFIG } from "../constants/oauth.js";
/**
* Cursor IDE OAuth Service
* Supports Import Token method from Cursor IDE's local SQLite database
*
* Token Location:
* - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
* - macOS: /Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb
* - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
*
* Database Keys:
* - cursorAuth/accessToken: The access token
* - storage.serviceMachineId: Machine ID for checksum
*/
export class CursorService {
constructor() {
this.config = CURSOR_CONFIG;
}
/**
* Generate Cursor checksum (jyh cipher)
* Algorithm: XOR timestamp bytes with rolling key (initial 165), then base64 encode
* Format: {encoded_timestamp},{machineId}
*/
generateChecksum(machineId) {
const timestamp = Math.floor(Date.now() / 1000).toString();
let key = 165;
const encoded = [];
for (let i = 0; i < timestamp.length; i++) {
const charCode = timestamp.charCodeAt(i);
encoded.push(charCode ^ key);
key = (key + charCode) & 0xff; // Rolling key update
}
const base64Encoded = Buffer.from(encoded).toString("base64");
return `${base64Encoded},${machineId}`;
}
/**
* Build request headers for Cursor API
*/
buildHeaders(accessToken, machineId, ghostMode = false) {
const checksum = this.generateChecksum(machineId);
return {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/connect+proto",
"Connect-Protocol-Version": "1",
"x-cursor-client-version": this.config.clientVersion,
"x-cursor-client-type": this.config.clientType,
"x-cursor-client-os": this.detectOS(),
"x-cursor-client-arch": this.detectArch(),
"x-cursor-client-device-type": "desktop",
"x-cursor-checksum": checksum,
"x-ghost-mode": ghostMode ? "true" : "false",
};
}
/**
* Detect OS for headers
*/
detectOS() {
if (typeof process !== "undefined") {
const platform = process.platform;
if (platform === "win32") return "windows";
if (platform === "darwin") return "macos";
return "linux";
}
return "linux";
}
/**
* Detect architecture for headers
*/
detectArch() {
if (typeof process !== "undefined") {
const arch = process.arch;
if (arch === "x64") return "x86_64";
if (arch === "arm64") return "aarch64";
return arch;
}
return "x86_64";
}
/**
* Validate and import token from Cursor IDE
* Note: We skip API validation because Cursor API uses complex protobuf format.
* Token will be validated when actually used for requests.
* @param {string} accessToken - Access token from state.vscdb
* @param {string} machineId - Machine ID from state.vscdb
*/
async validateImportToken(accessToken, machineId) {
// Basic validation
if (!accessToken || typeof accessToken !== "string") {
throw new Error("Access token is required");
}
if (!machineId || typeof machineId !== "string") {
throw new Error("Machine ID is required");
}
// Token format validation (Cursor tokens are typically long strings)
if (accessToken.length < 50) {
throw new Error("Invalid token format. Token appears too short.");
}
// Machine ID format validation (should be UUID-like)
const uuidRegex = /^[a-f0-9-]{32,}$/i;
if (!uuidRegex.test(machineId.replace(/-/g, ""))) {
throw new Error("Invalid machine ID format. Expected UUID format.");
}
// Note: We don't validate against API because Cursor uses complex protobuf.
// Token will be validated when used for actual requests.
return {
accessToken,
machineId,
expiresIn: 86400, // Cursor tokens typically last 24 hours
authMethod: "imported",
};
}
/**
* Extract user info from token if possible
* Cursor tokens may contain encoded user info
*/
extractUserInfo(accessToken) {
try {
// Try to decode as JWT
const parts = accessToken.split(".");
if (parts.length === 3) {
let payload = parts[1];
while (payload.length % 4) {
payload += "=";
}
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
);
return {
email: decoded.email || decoded.sub,
userId: decoded.sub || decoded.user_id,
};
}
} catch {
// Token is not a JWT, that's okay
}
return null;
}
/**
* Get token storage path instructions for user
*/
getTokenStorageInstructions() {
return {
title: "How to get your Cursor token",
steps: [
"1. Open Cursor IDE and make sure you're logged in",
"2. Find the state.vscdb file:",
` - Linux: ${this.config.tokenStoragePaths.linux}`,
` - macOS: ${this.config.tokenStoragePaths.macos}`,
` - Windows: ${this.config.tokenStoragePaths.windows}`,
"3. Open the database with SQLite browser or CLI:",
" sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='cursorAuth/accessToken'\"",
"4. Also get the machine ID:",
" sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='storage.serviceMachineId'\"",
"5. Paste both values in the form below",
],
alternativeMethod: [
"Or use this one-liner to get both values:",
"sqlite3 state.vscdb \"SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')\"",
],
};
}
}
+1
View File
@@ -12,4 +12,5 @@ export { AntigravityService } from "./antigravity.js";
export { OpenAIService } from "./openai.js";
export { GitHubService } from "./github.js";
export { KiroService } from "./kiro.js";
export { CursorService } from "./cursor.js";
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
/**
* Cursor Auth Modal
* Import token from Cursor IDE's local SQLite database
*
* Token Location:
* - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
* - macOS: /Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb
* - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
*
* Database Keys:
* - cursorAuth/accessToken: The access token
* - storage.serviceMachineId: Machine ID for checksum
*/
export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const [accessToken, setAccessToken] = useState("");
const [machineId, setMachineId] = useState("");
const [error, setError] = useState(null);
const [importing, setImporting] = useState(false);
const { copied, copy } = useCopyToClipboard();
const handleImportToken = async () => {
if (!accessToken.trim()) {
setError("Please enter an access token");
return;
}
if (!machineId.trim()) {
setError("Please enter a machine ID");
return;
}
setImporting(true);
setError(null);
try {
const res = await fetch("/api/oauth/cursor/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: accessToken.trim(),
machineId: machineId.trim(),
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
// Success - close modal and trigger refresh
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
};
const linuxCommand = `sqlite3 ~/.config/Cursor/User/globalStorage/state.vscdb "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`;
const macCommand = `sqlite3 "/Users/$USER/Library/Application Support/Cursor/User/globalStorage/state.vscdb" "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`;
return (
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
<div className="flex-1 text-sm">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
Prerequisites
</p>
<p className="text-blue-800 dark:text-blue-200">
Make sure you are logged in to Cursor IDE first. Tokens are stored in the local SQLite database.
</p>
</div>
</div>
</div>
{/* Instructions */}
<div className="space-y-3">
<p className="text-sm font-medium">How to get your tokens:</p>
<div className="bg-sidebar/50 p-3 rounded-lg space-y-2">
<p className="text-xs text-text-muted">Linux:</p>
<div className="flex items-start gap-2">
<code className="text-xs bg-sidebar px-2 py-1 rounded flex-1 overflow-x-auto whitespace-pre">
{linuxCommand}
</code>
<button
onClick={() => copy(linuxCommand, "linux-cmd")}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary flex-shrink-0"
title="Copy command"
>
<span className="material-symbols-outlined text-sm">
{copied === "linux-cmd" ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
<div className="bg-sidebar/50 p-3 rounded-lg space-y-2">
<p className="text-xs text-text-muted">macOS:</p>
<div className="flex items-start gap-2">
<code className="text-xs bg-sidebar px-2 py-1 rounded flex-1 overflow-x-auto whitespace-pre">
{macCommand}
</code>
<button
onClick={() => copy(macCommand, "mac-cmd")}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary flex-shrink-0"
title="Copy command"
>
<span className="material-symbols-outlined text-sm">
{copied === "mac-cmd" ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
<div className="text-xs text-text-muted">
<p className="mb-1">Database locations:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Linux: <code className="bg-sidebar px-1 rounded">~/.config/Cursor/User/globalStorage/state.vscdb</code></li>
<li>macOS: <code className="bg-sidebar px-1 rounded">/Users/&lt;user&gt;/Library/Application Support/Cursor/User/globalStorage/state.vscdb</code></li>
<li>Windows: <code className="bg-sidebar px-1 rounded">%APPDATA%\Cursor\User\globalStorage\state.vscdb</code></li>
</ul>
</div>
</div>
{/* Access Token Input */}
<div>
<label className="block text-sm font-medium mb-2">
Access Token <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Paste your access token here..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
<p className="text-xs text-text-muted mt-1">
From key: <code className="bg-sidebar px-1 rounded">cursorAuth/accessToken</code>
</p>
</div>
{/* Machine ID Input */}
<div>
<label className="block text-sm font-medium mb-2">
Machine ID <span className="text-red-500">*</span>
</label>
<Input
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
From key: <code className="bg-sidebar px-1 rounded">storage.serviceMachineId</code>
</p>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !accessToken.trim() || !machineId.trim()}
>
{importing ? "Importing..." : "Import Token"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
CursorAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
+1
View File
@@ -21,6 +21,7 @@ export { default as RequestLogger } from "./RequestLogger";
export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
// Layouts
+1
View File
@@ -10,6 +10,7 @@ export const OAUTH_PROVIDERS = {
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
};
export const APIKEY_PROVIDERS = {