feat(cursor): 支持独立控制面账号登录

This commit is contained in:
aike1202
2026-08-01 22:05:08 +08:00
parent 00f52166b2
commit 9d316c0b3d
16 changed files with 1204 additions and 63 deletions
@@ -0,0 +1,142 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
getCursorAccountStatus,
startCursorAccountLogin,
} from "@/services/clientApi";
import { toUserError } from "@/state/appState";
import { computed, onMounted, onUnmounted, ref } from "vue";
const cursorAccountStatus = ref({
state: "signed_out",
authId: "",
email: "",
error: "",
});
const cursorAccountBusy = ref(false);
let cursorAccountTimer = null;
const cursorAccountSignedIn = computed(
() => cursorAccountStatus.value.state === "signed_in",
);
const cursorAccountWaiting = computed(
() => cursorAccountStatus.value.state === "waiting",
);
const cursorAccountStateText = computed(() => {
if (cursorAccountSignedIn.value) return "已经登录";
if (cursorAccountWaiting.value) return "等待浏览器登录";
return "未连接";
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
async function refreshCursorAccountStatus() {
cursorAccountStatus.value = await getCursorAccountStatus();
}
async function handleCursorAccountLogin() {
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
await showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
}
}
async function handleCursorAccountDisconnect() {
const confirmed = await showModal({
title: "退出登录",
content: "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
confirmText: "退出登录",
cancelText: "取消",
showCancel: true,
});
if (!confirmed) return;
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
await showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
}
onMounted(async () => {
await refreshCursorAccountStatus().catch(() => {});
cursorAccountTimer = window.setInterval(() => {
if (cursorAccountWaiting.value) {
void refreshCursorAccountStatus().catch(() => {});
}
}, 1500);
});
onUnmounted(() => {
if (cursorAccountTimer) {
window.clearInterval(cursorAccountTimer);
cursorAccountTimer = null;
}
});
</script>
<template>
<Card>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-base font-medium text-white">Cursor 控制面账号</h2>
<span
class="rounded-full border border-[#3a3a3a] bg-[#202020] px-2 py-0.5 text-xs text-[#b8b8b8]"
>
{{ cursorAccountStateText }}
</span>
</div>
<div
v-if="cursorAccountSignedIn && (cursorAccountStatus.email || cursorAccountStatus.authId)"
class="mt-1 truncate text-sm text-[#d0d0d0]"
>
{{ cursorAccountStatus.email || cursorAccountStatus.authId }}
</div>
<div class="mt-1 text-sm text-[#a3a3a3]">
独立用于插件Skills MCP不会改变 Cursor 客户端当前账号
</div>
<div v-if="cursorAccountWaiting" class="mt-1 text-sm text-[#d6a84b]">
请在浏览器完成登录完成后返回 Cursor 重新打开插件市场
</div>
<div
v-if="cursorAccountStatus.error"
class="mt-1 break-all text-sm text-[#e06c75]"
>
{{ cursorAccountStatus.error }}
</div>
</div>
<Button
v-if="cursorAccountSignedIn"
:disabled="cursorAccountBusy"
@click="handleCursorAccountDisconnect"
>
退出登录
</Button>
<Button
v-else
variant="primary"
:disabled="cursorAccountBusy || cursorAccountWaiting"
@click="handleCursorAccountLogin"
>
{{ cursorAccountWaiting ? "等待登录..." : "登录 Cursor" }}
</Button>
</div>
</Card>
</template>
+210 -41
View File
@@ -19,8 +19,8 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 256,
"column": 1
"line": 255,
"column": 165
}
]
},
@@ -43,8 +43,8 @@
"refs": [
{
"file": "src/views/Config.vue",
"line": 72,
"column": 1
"line": 71,
"column": 48
}
]
},
@@ -108,8 +108,8 @@
"refs": [
{
"file": "src/views/Config.vue",
"line": 58,
"column": 1
"line": 57,
"column": 48
}
]
},
@@ -197,8 +197,8 @@
"refs": [
{
"file": "src/components/HomeMetricsCard.vue",
"line": 338,
"column": 1
"line": 337,
"column": 65
}
]
},
@@ -298,6 +298,18 @@
}
]
},
"1c631615c1d85c9e": {
"source": "登录 Cursor",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 138,
"column": 12
}
]
},
"1e238093b79b3165": {
"source": "留空时默认 65536",
"kind": "text",
@@ -349,12 +361,12 @@
},
{
"file": "src/views/Home.vue",
"line": 118,
"line": 119,
"column": 27
},
{
"file": "src/views/Home.vue",
"line": 126,
"line": 127,
"column": 27
},
{
@@ -407,8 +419,8 @@
"refs": [
{
"file": "src/views/Config.vue",
"line": 90,
"column": 1
"line": 89,
"column": 48
}
]
},
@@ -429,6 +441,11 @@
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 62,
"column": 17
},
{
"file": "src/components/ModelAdapterModal.vue",
"line": 297,
@@ -531,8 +548,8 @@
"refs": [
{
"file": "src/components/ModelAdapterTestCard.vue",
"line": 151,
"column": 1
"line": 150,
"column": 7
}
]
},
@@ -579,8 +596,8 @@
"refs": [
{
"file": "src/components/ModelAdapterTestCard.vue",
"line": 130,
"column": 1
"line": 129,
"column": 60
}
]
},
@@ -659,6 +676,28 @@
}
]
},
"3ab8cc15939f3b5c": {
"source": "退出登录",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 59,
"column": 12
},
{
"file": "src/components/CursorAccountCard.vue",
"line": 61,
"column": 18
},
{
"file": "src/components/CursorAccountCard.vue",
"line": 130,
"column": 1
}
]
},
"3af7e5489e61ea51": {
"source": "刷新中",
"kind": "text",
@@ -717,6 +756,18 @@
}
]
},
"3d52574ce1500561": {
"source": "未连接",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 31,
"column": 10
}
]
},
"3ea83f9f55062582": {
"source": "发布时间:{0}",
"kind": "template",
@@ -892,8 +943,8 @@
"refs": [
{
"file": "src/components/ModelAdapterTestCard.vue",
"line": 125,
"column": 1
"line": 124,
"column": 9
}
]
},
@@ -1010,7 +1061,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 106,
"line": 107,
"column": 27
}
]
@@ -1150,6 +1201,18 @@
}
]
},
"688102a402ba015a": {
"source": "等待登录...",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 138,
"column": 12
}
]
},
"699fe7ade5407687": {
"source": "直连模式",
"kind": "text",
@@ -1157,7 +1220,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 185,
"line": 186,
"column": 11
}
]
@@ -1196,6 +1259,16 @@
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 37,
"column": 30
},
{
"file": "src/components/CursorAccountCard.vue",
"line": 37,
"column": 48
},
{
"file": "src/state/appState.js",
"line": 23,
@@ -1213,12 +1286,12 @@
},
{
"file": "src/views/Home.vue",
"line": 89,
"line": 90,
"column": 30
},
{
"file": "src/views/Home.vue",
"line": 89,
"line": 90,
"column": 48
},
{
@@ -1426,7 +1499,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 96,
"line": 97,
"column": 27
}
]
@@ -1455,8 +1528,8 @@
"refs": [
{
"file": "src/components/HomeMetricsCard.vue",
"line": 390,
"column": 1
"line": 389,
"column": 65
}
]
},
@@ -1484,6 +1557,18 @@
}
]
},
"83be9cac28873059": {
"source": "Cursor 控制面账号",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 99,
"column": 56
}
]
},
"8672864e90417138": {
"source": "最高",
"kind": "text",
@@ -1556,7 +1641,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 188,
"line": 189,
"column": 11
}
]
@@ -1595,7 +1680,7 @@
},
{
"file": "src/views/Home.vue",
"line": 205,
"line": 208,
"column": 68
}
]
@@ -1708,8 +1793,8 @@
"refs": [
{
"file": "src/components/HomeMetricsCard.vue",
"line": 342,
"column": 1
"line": 341,
"column": 23
}
]
},
@@ -1845,7 +1930,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 136,
"line": 137,
"column": 29
}
]
@@ -1893,8 +1978,8 @@
"refs": [
{
"file": "src/views/ModelEditor.vue",
"line": 297,
"column": 1
"line": 296,
"column": 97
}
]
},
@@ -2016,7 +2101,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 187,
"line": 188,
"column": 11
}
]
@@ -2147,7 +2232,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 201,
"line": 204,
"column": 47
}
]
@@ -2222,7 +2307,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 136,
"line": 137,
"column": 50
}
]
@@ -2400,6 +2485,18 @@
}
]
},
"c3d46b387eeadb23": {
"source": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 60,
"column": 14
}
]
},
"c3e9c3c60020b8b7": {
"source": "选择模式",
"kind": "text",
@@ -2431,11 +2528,23 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 204,
"line": 207,
"column": 63
}
]
},
"c8a52b66651d294c": {
"source": "退出登录失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 71,
"column": 27
}
]
},
"c8c14507b2d37395": {
"source": "推理强度",
"kind": "text",
@@ -2525,11 +2634,23 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 186,
"line": 187,
"column": 11
}
]
},
"cfa6c803eb3fc713": {
"source": "等待浏览器登录",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 30,
"column": 42
}
]
},
"d0325067fed88e5a": {
"source": "缓存命中率 {0}",
"kind": "template",
@@ -2549,7 +2670,7 @@
"refs": [
{
"file": "src/views/Home.vue",
"line": 133,
"line": 134,
"column": 27
}
]
@@ -2653,6 +2774,18 @@
}
]
},
"d6ce4f0f88178144": {
"source": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 113,
"column": 1
}
]
},
"d7889896c5b7732a": {
"source": "Anthropic 额外参数 JSON",
"kind": "text",
@@ -2771,8 +2904,8 @@
"refs": [
{
"file": "src/views/Config.vue",
"line": 102,
"column": 1
"line": 101,
"column": 48
}
]
},
@@ -2812,11 +2945,35 @@
},
{
"file": "src/views/Home.vue",
"line": 200,
"line": 203,
"column": 56
}
]
},
"e4343921c928a856": {
"source": "登录失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 50,
"column": 27
}
]
},
"e53580f8031f13c0": {
"source": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 116,
"column": 1
}
]
},
"e552c2accdbf5178": {
"source": "新增模型",
"kind": "text",
@@ -2841,6 +2998,18 @@
}
]
},
"e8a0a6053998ebfa": {
"source": "已经登录",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 29,
"column": 43
}
]
},
"eaffd48cd2ea9f1a": {
"source": "例如:https://api.anthropic.com",
"kind": "text",
+12
View File
@@ -23,6 +23,7 @@
"1af38868896cf53d": "Routing mode only supports local or upstream",
"1baddde657dd2720": "Current outbound requests use system proxy",
"1bc77f5ab979f4c1": "Add Model Settings",
"1c631615c1d85c9e": "Log in to Cursor",
"1e238093b79b3165": "Uses 65536 by default when left blank",
"21296ab18ad9af25": "Extra Params JSON",
"24343a2096988d42": "Failed to open",
@@ -46,10 +47,12 @@
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3ab8cc15939f3b5c": "Log out",
"3af7e5489e61ea51": "Refreshing",
"3bf8512aa520ed21": "Local Service Mode",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language",
"3d52574ce1500561": "Not connected",
"3ea83f9f55062582": "Release date: {0}",
"3edda85621fd03b2": "model adapters",
"3fd47edce45b3603": "Close",
@@ -84,6 +87,7 @@
"66af574b8948fe83": "{0} API key cannot be empty",
"6744b4c6a9aa0038": "Disabled",
"675109292da4eb36": "Not tested yet",
"688102a402ba015a": "Waiting for login...",
"699fe7ade5407687": "Direct Mode",
"6a7b96f399e58138": "e.g. sk-xxxxxx",
"6aa8f49cc992dfd7": "Test",
@@ -106,6 +110,7 @@
"80296f4aa3f4543b": "Cache Read/Write",
"81123c56d5d880d0": "API Key",
"8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.",
"83be9cac28873059": "Cursor Control Plane Account",
"8672864e90417138": "Max",
"86df7ec743047234": "Service running",
"87ed126f7bd1121e": "Routing Mode",
@@ -174,9 +179,11 @@
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
"bef280f9eb392495": "Conversation Turns",
"c228558cf257fc49": "Delete failed",
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?",
"c3e9c3c60020b8b7": "Select Mode",
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
"c69f5bce63b9f14c": "Settings Folder",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
@@ -184,6 +191,7 @@
"ca1d1059408b3837": "Invalid turns: {0}",
"cd7ca5fb221e1c53": "{0} cannot be empty",
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
"cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}",
"d08fd4224abcd69d": "Switch failed",
"d1bde4a4e057b2c7": "[MainLayout] Failed to load author info",
@@ -193,6 +201,7 @@
"d373809ab86ba93b": "Copy",
"d3b1da3088ddd334": "Model test failed",
"d53d32f1a1211371": "Custom Headers JSON",
"d6ce4f0f88178144": "Used only for Plugins, Skills, and MCP; does not change the account in the Cursor client",
"d7889896c5b7732a": "Anthropic Extra Params JSON",
"d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)",
"d95e5cb6bdcee553": "Include Cache Creation",
@@ -205,8 +214,11 @@
"e01c5dae36cf8c35": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority.",
"e14c41ef2b7253c9": "Total request tokens: {0}",
"e406825e0a72d2c2": "Local Settings",
"e4343921c928a856": "Login failed",
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
"e552c2accdbf5178": "Add Model",
"e6faccfddce722e8": "Cache read tokens: {0}",
"e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
"eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.",
"ec3b17a75db49e24": "{0} t/s | First token {1}",
+12
View File
@@ -23,6 +23,7 @@
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
"1bc77f5ab979f4c1": "モデル設定を追加",
"1c631615c1d85c9e": "Cursor にログイン",
"1e238093b79b3165": "空欄で 65536",
"21296ab18ad9af25": "追加パラメータ JSON",
"24343a2096988d42": "開けませんでした",
@@ -46,10 +47,12 @@
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3ab8cc15939f3b5c": "ログアウト",
"3af7e5489e61ea51": "更新中",
"3bf8512aa520ed21": "ローカルサービスモード",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語",
"3d52574ce1500561": "未接続",
"3ea83f9f55062582": "公開日時: {0}",
"3edda85621fd03b2": "件のモデルアダプター",
"3fd47edce45b3603": "閉じる",
@@ -84,6 +87,7 @@
"66af574b8948fe83": "{0} の API キーは必須です",
"6744b4c6a9aa0038": "無効化",
"675109292da4eb36": "まだテストしていません",
"688102a402ba015a": "ログインを待っています...",
"699fe7ade5407687": "直結モード",
"6a7b96f399e58138": "例: sk-xxxxxx",
"6aa8f49cc992dfd7": "テスト",
@@ -106,6 +110,7 @@
"80296f4aa3f4543b": "キャッシュ読み書き",
"81123c56d5d880d0": "API キー",
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
"8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中",
"87ed126f7bd1121e": "ルーティングモード",
@@ -174,9 +179,11 @@
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
"bef280f9eb392495": "会話ターン",
"c228558cf257fc49": "削除に失敗しました",
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c3e9c3c60020b8b7": "モードを選択",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c69f5bce63b9f14c": "設定フォルダー",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
@@ -184,6 +191,7 @@
"ca1d1059408b3837": "異常ターン: {0}",
"cd7ca5fb221e1c53": "{0}は空にできません",
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
"d08fd4224abcd69d": "切替に失敗しました",
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
@@ -193,6 +201,7 @@
"d373809ab86ba93b": "コピー",
"d3b1da3088ddd334": "モデルテストに失敗しました",
"d53d32f1a1211371": "カスタムヘッダー JSON",
"d6ce4f0f88178144": "プラグイン、Skills、MCP 専用です。Cursor クライアントの現在のアカウントは変更しません",
"d7889896c5b7732a": "Anthropic 追加パラメータ JSON",
"d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)",
"d95e5cb6bdcee553": "キャッシュ作成を含める",
@@ -205,8 +214,11 @@
"e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。",
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
"e406825e0a72d2c2": "ローカル設定",
"e4343921c928a856": "ログインに失敗しました",
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
"eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。",
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
+12
View File
@@ -23,6 +23,7 @@
"1af38868896cf53d": "Режим маршрутизации поддерживает только local или upstream",
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
"1bc77f5ab979f4c1": "Добавить настройки модели",
"1c631615c1d85c9e": "Войти в Cursor",
"1e238093b79b3165": "Если оставить пустым, используется 65536",
"21296ab18ad9af25": "Дополнительные параметры JSON",
"24343a2096988d42": "Не удалось открыть",
@@ -46,10 +47,12 @@
"37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание",
"3ab8cc15939f3b5c": "Выйти",
"3af7e5489e61ea51": "Обновление",
"3bf8512aa520ed21": "Режим локального сервиса",
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
"3d13868593ae4eeb": "Язык интерфейса",
"3d52574ce1500561": "Не подключено",
"3ea83f9f55062582": "Дата выпуска: {0}",
"3edda85621fd03b2": "адаптеров моделей",
"3fd47edce45b3603": "Закрыть",
@@ -84,6 +87,7 @@
"66af574b8948fe83": "Ключ API {0} не может быть пустым",
"6744b4c6a9aa0038": "Выключено",
"675109292da4eb36": "Еще не проверено",
"688102a402ba015a": "Ожидание входа...",
"699fe7ade5407687": "Прямой режим",
"6a7b96f399e58138": "например, sk-xxxxxx",
"6aa8f49cc992dfd7": "Проверить",
@@ -106,6 +110,7 @@
"80296f4aa3f4543b": "Чтение/запись кеша",
"81123c56d5d880d0": "Ключ API",
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен",
"87ed126f7bd1121e": "Режим маршрутизации",
@@ -174,9 +179,11 @@
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
"bef280f9eb392495": "Ходы диалога",
"c228558cf257fc49": "Не удалось удалить",
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c3e9c3c60020b8b7": "Выберите режим",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c69f5bce63b9f14c": "Папка настроек",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель",
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
@@ -184,6 +191,7 @@
"ca1d1059408b3837": "Ошибочных ходов: {0}",
"cd7ca5fb221e1c53": "{0} не может быть пустым",
"ce46f23cea3bf3c5": "Если включено, Cursor подключается напрямую к официальному сервису. Не включайте этот режим.",
"cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
"d08fd4224abcd69d": "Не удалось переключить",
"d1bde4a4e057b2c7": "[MainLayout] Не удалось загрузить сведения об авторе",
@@ -193,6 +201,7 @@
"d373809ab86ba93b": "Копировать",
"d3b1da3088ddd334": "Проверка модели не пройдена",
"d53d32f1a1211371": "Пользовательские заголовки JSON",
"d6ce4f0f88178144": "Используется только для Plugins, Skills и MCP; текущий аккаунт клиента Cursor не изменяется",
"d7889896c5b7732a": "Дополнительные параметры Anthropic JSON",
"d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)",
"d95e5cb6bdcee553": "Учитывать создание кеша",
@@ -205,8 +214,11 @@
"e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.",
"e14c41ef2b7253c9": "Всего токенов запроса: {0}",
"e406825e0a72d2c2": "Локальные настройки",
"e4343921c928a856": "Не удалось войти",
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель",
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
"eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.",
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
+12
View File
@@ -23,6 +23,7 @@
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
"1baddde657dd2720": "当前出站请求使用系统代理",
"1bc77f5ab979f4c1": "新增模型配置",
"1c631615c1d85c9e": "登录 Cursor",
"1e238093b79b3165": "留空时默认 65536",
"21296ab18ad9af25": "额外参数 JSON",
"24343a2096988d42": "打开失败",
@@ -46,10 +47,12 @@
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3ab8cc15939f3b5c": "退出登录",
"3af7e5489e61ea51": "刷新中",
"3bf8512aa520ed21": "本地服务模式",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言",
"3d52574ce1500561": "未连接",
"3ea83f9f55062582": "发布时间:{0}",
"3edda85621fd03b2": "个模型适配器",
"3fd47edce45b3603": "关闭",
@@ -84,6 +87,7 @@
"66af574b8948fe83": "{0} 的访问密钥不能为空",
"6744b4c6a9aa0038": "已关闭",
"675109292da4eb36": "尚未测试",
"688102a402ba015a": "等待登录...",
"699fe7ade5407687": "直连模式",
"6a7b96f399e58138": "例如:sk-xxxxxx",
"6aa8f49cc992dfd7": "测试",
@@ -106,6 +110,7 @@
"80296f4aa3f4543b": "缓存读写",
"81123c56d5d880d0": "访问密钥",
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"83be9cac28873059": "Cursor 控制面账号",
"8672864e90417138": "最高",
"86df7ec743047234": "服务运行中",
"87ed126f7bd1121e": "运行模式",
@@ -174,9 +179,11 @@
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
"bef280f9eb392495": "对话轮次",
"c228558cf257fc49": "删除失败",
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c3e9c3c60020b8b7": "选择模式",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c69f5bce63b9f14c": "设置文件夹",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
@@ -184,6 +191,7 @@
"ca1d1059408b3837": "异常轮次:{0}",
"cd7ca5fb221e1c53": "{0}不能为空",
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
"cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}",
"d08fd4224abcd69d": "切换失败",
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
@@ -193,6 +201,7 @@
"d373809ab86ba93b": "拷贝",
"d3b1da3088ddd334": "模型测试失败",
"d53d32f1a1211371": "自定义请求头 JSON",
"d6ce4f0f88178144": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号",
"d7889896c5b7732a": "Anthropic 额外参数 JSON",
"d7da2aabd35772ec": "例如:200000(留空用默认值)",
"d95e5cb6bdcee553": "计入缓存创建",
@@ -205,8 +214,11 @@
"e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
"e14c41ef2b7253c9": "总请求:{0}",
"e406825e0a72d2c2": "本地配置",
"e4343921c928a856": "登录失败",
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型",
"e6faccfddce722e8": "缓存读取:{0}",
"e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
+15
View File
@@ -1,7 +1,10 @@
import {
DisconnectCursorAccount,
GetCursorAccountStatus,
GetState,
LoadUserConfig,
SaveUserConfig,
StartCursorAccountLogin,
StartProxy,
StopProxy,
} from "@bindings/cursor/internal/bridge/proxyservice.js";
@@ -62,6 +65,18 @@ export function saveUserConfig(payload) {
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
}
export function getCursorAccountStatus() {
return withApiLogging("GetCursorAccountStatus", undefined, () => GetCursorAccountStatus());
}
export function startCursorAccountLogin() {
return withApiLogging("StartCursorAccountLogin", undefined, () => StartCursorAccountLogin());
}
export function disconnectCursorAccount() {
return withApiLogging("DisconnectCursorAccount", undefined, () => DisconnectCursorAccount());
}
export function getProxyState() {
return withApiLogging("GetState", undefined, () => GetState());
}
+4 -1
View File
@@ -3,6 +3,7 @@ import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Switch from "@/components/ui/Switch.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import CursorAccountCard from "@/components/CursorAccountCard.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import { getAdRuntime } from "@/services/clientApi";
@@ -149,7 +150,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex flex-col gap-4 p-4 pt-0 text-[#e5e5e5]">
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-4 pt-0 text-[#e5e5e5]">
<HomeMetricsCard
:metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading"
@@ -194,6 +195,8 @@ onBeforeUnmount(() => {
</div>
</Card>
<CursorAccountCard />
<Card>
<div class="flex items-center justify-between gap-4">
<div>
+94 -19
View File
@@ -27,10 +27,11 @@ const healthPath = "/healthz"
const tabServerBaseURL = "https://tab.leokun.cn"
type Host struct {
store *serverconfig.Store
listenAddr string
configs *serverconfig.Manager
healthHTTP *http.Client
store *serverconfig.Store
listenAddr string
configs *serverconfig.Manager
healthHTTP *http.Client
controlPlaneAuth upstream.AuthorizationProvider
runMu sync.RWMutex
httpServer *http.Server
@@ -40,7 +41,7 @@ type Host struct {
mux http.Handler
}
func NewHost(store *serverconfig.Store) (*Host, error) {
func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationProvider) (*Host, error) {
if store == nil {
return nil, fmt.Errorf("backend config store is required")
}
@@ -50,10 +51,11 @@ func NewHost(store *serverconfig.Store) (*Host, error) {
}
cfg := configs.Current()
host := &Host{
store: store,
listenAddr: cfg.BackendListenAddr,
configs: configs,
healthHTTP: newLoopbackHTTPClient(),
store: store,
listenAddr: cfg.BackendListenAddr,
configs: configs,
healthHTTP: newLoopbackHTTPClient(),
controlPlaneAuth: controlPlaneAuth,
}
if err := host.rebuild(cfg); err != nil {
return nil, err
@@ -515,15 +517,25 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
server.POST("/aiserver.v1.DashboardService/GetManagedSkills",
server.Name("dashboard_get_managed_skills"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_managed_skills",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetManagedSkillsResponse",
MockBuilder: upstream.DashboardManagedSkillsMockBuilder,
})),
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_managed_skills",
})),
server.Local(cursorControlPlaneAction(
host.controlPlaneAuth,
routeDeps,
"dashboard_get_managed_skills",
upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_managed_skills",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.GetManagedSkillsResponse",
MockBuilder: upstream.DashboardManagedSkillsMockBuilder,
}),
)),
server.Upstream(cursorControlPlaneAction(
host.controlPlaneAuth,
routeDeps,
"dashboard_get_managed_skills",
upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
Name: "dashboard_get_managed_skills",
}),
)),
),
server.POST("/aiserver.v1.DashboardService/GetMe",
server.Name("dashboard_get_me"),
@@ -590,7 +602,24 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
Name: "dashboard_is_on_new_pricing",
})),
),
// tabServerUpstreamProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMarketplace", "dashboard_add_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMcpServersFromPlugin", "dashboard_add_mcp_servers_from_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/BatchGetPluginMcpConfig", "dashboard_batch_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetAvailableMcpServers", "dashboard_get_available_mcp_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPlugin", "dashboard_get_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPluginMcpConfig", "dashboard_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/InstallUserPlugin", "dashboard_install_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplacePlugins", "dashboard_list_marketplace_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplaces", "dashboard_list_marketplaces", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListUserPluginInstalls", "dashboard_list_user_plugin_installs", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RefreshMarketplace", "dashboard_refresh_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins", "dashboard_register_marketplace_and_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RemoveMarketplace", "dashboard_remove_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ResolvePluginsByRef", "dashboard_resolve_plugins_by_ref", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UninstallUserPlugin", "dashboard_uninstall_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UpdateUserPluginInstall", "dashboard_update_user_plugin_install", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.MCPRegistryService/GetKnownServers", "mcp_registry_get_known_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
server.Any("/aiserver.v1.DashboardService/*",
server.Name("dashboard"),
server.HTTP(),
@@ -762,6 +791,52 @@ func tabServerUpstreamProcedure(pattern string, name string, protocol server.Rou
)
}
func cursorControlPlaneProcedure(
pattern string,
name string,
protocol server.RouteOption,
authorizationProvider upstream.AuthorizationProvider,
deps upstream.Dependencies,
) server.Option {
notFound := func(ctx *server.Context) error {
http.NotFound(ctx.Writer, ctx.Request)
return nil
}
return server.POST(pattern,
server.Name(name),
protocol,
server.Local(cursorControlPlaneAction(authorizationProvider, deps, name, notFound)),
server.Upstream(cursorControlPlaneAction(
authorizationProvider,
deps,
name,
upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name}),
)),
)
}
func cursorControlPlaneAction(
authorizationProvider upstream.AuthorizationProvider,
deps upstream.Dependencies,
name string,
fallback server.HandlerFunc,
) server.HandlerFunc {
direct := upstream.AuthenticatedDirectAction(deps, upstream.CompatRouteConfig{Name: name}, authorizationProvider)
return func(ctx *server.Context) error {
if authorizationProvider == nil || !authorizationProvider.SignedIn() {
return fallback(ctx)
}
if ctx == nil || ctx.Request == nil || ctx.Request.URL == nil {
return fmt.Errorf("Cursor 控制面请求上下文无效")
}
targetURL := *ctx.Request.URL
targetURL.Scheme = "https"
targetURL.Host = "api2.cursor.sh:443"
ctx.UpstreamURL = &targetURL
return direct(ctx)
}
}
type serverSystemSettings struct {
configs *serverconfig.Manager
}
@@ -2,6 +2,7 @@ package upstream
import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
@@ -29,6 +30,34 @@ func DirectAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
}
}
// AuthenticatedDirectAction forwards a Cursor control-plane request with the
// independent desktop account after the local-mode identity rewrite has run.
func AuthenticatedDirectAction(deps Dependencies, cfg CompatRouteConfig, authorizationProvider AuthorizationProvider) server.HandlerFunc {
return func(ctx *server.Context) error {
reqCtx, _, err := newCompatRouteObjects(ctx, deps, cfg)
if err != nil {
return err
}
if reqCtx == nil || reqCtx.Request == nil {
return fmt.Errorf("Cursor 控制面请求上下文无效")
}
if authorizationProvider == nil {
return fmt.Errorf("Cursor 账号服务未初始化")
}
authorization, err := authorizationProvider.Authorization(reqCtx.Request.Context())
if err != nil {
return err
}
_, err = ForwardToUpstream(reqCtx, ForwardOptions{
PatchHeaders: func(headers http.Header) {
headers.Set("Authorization", authorization)
headers.Set("x-cursor-checksum", BuildCursorChecksum(authorization))
},
})
return err
}
}
func FixedStatusAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
@@ -20,6 +20,13 @@ type SystemSettingService interface {
ResolveModelAdapters(context.Context) ([]legacyruntime.ModelAdapterConfig, error)
}
// AuthorizationProvider supplies the independent Cursor account used only by
// official control-plane requests such as Plugins, Skills, and MCP registry.
type AuthorizationProvider interface {
Authorization(context.Context) (string, error)
SignedIn() bool
}
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
+18
View File
@@ -24,6 +24,9 @@ type ModelAdapterTestResult = client.ModelAdapterTestResult
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
type CursorAccountStatus = client.CursorAccountStatus
// LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。
type LicenseActionRequest = client.LicenseActionRequest
@@ -91,6 +94,21 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
return s.core.SaveUserConfig(cfg)
}
// GetCursorAccountStatus 返回 cursor-byok 独立 Cursor 账号的脱敏状态。
func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
return s.core.GetCursorAccountStatus()
}
// StartCursorAccountLogin 打开官方浏览器登录并异步等待结果。
func (s *ProxyService) StartCursorAccountLogin() (CursorAccountStatus, error) {
return s.core.StartCursorAccountLogin()
}
// DisconnectCursorAccount 只断开 cursor-byok 自己的账号。
func (s *ProxyService) DisconnectCursorAccount() (CursorAccountStatus, error) {
return s.core.DisconnectCursorAccount()
}
// TestModelAdapter 用于处理与 TestModelAdapter 相关的逻辑。
func (s *ProxyService) TestModelAdapter(adapter ModelAdapterConfig) (ModelAdapterTestResult, error) {
return s.core.TestModelAdapter(adapter)
+35
View File
@@ -0,0 +1,35 @@
package client
import (
"context"
"fmt"
"time"
"cursor/internal/cursoraccount"
)
type CursorAccountStatus = cursoraccount.Status
func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
if s == nil || s.cursorAccount == nil {
return CursorAccountStatus{State: cursoraccount.StateSignedOut}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.cursorAccount.EnsureEmail(ctx)
return s.cursorAccount.Status()
}
func (s *ProxyService) StartCursorAccountLogin() (CursorAccountStatus, error) {
if s == nil || s.cursorAccount == nil {
return CursorAccountStatus{State: cursoraccount.StateError}, fmt.Errorf("Cursor 账号服务未初始化")
}
return s.cursorAccount.StartLogin()
}
func (s *ProxyService) DisconnectCursorAccount() (CursorAccountStatus, error) {
if s == nil || s.cursorAccount == nil {
return CursorAccountStatus{State: cursoraccount.StateSignedOut}, nil
}
return s.cursorAccount.Disconnect()
}
+3
View File
@@ -265,6 +265,9 @@ func (s *ProxyService) ShutdownForQuit() {
finalErr = errors.Join(finalErr, err)
}
}
if s.cursorAccount != nil {
s.cursorAccount.Shutdown()
}
if finalErr != nil {
s.setLastError(finalErr)
}
+10 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"path/filepath"
"sync"
"time"
@@ -11,6 +12,7 @@ import (
backend "cursor/internal/backend"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs"
"cursor/internal/cursoraccount"
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
@@ -35,6 +37,8 @@ type ProxyService struct {
certManager *certs.Manager
// backendHost 表示当前嵌入式 backend 服务。
backendHost *backend.Host
// cursorAccount 持有仅供插件、Skills 和 MCP 控制面使用的真实 Cursor 身份。
cursorAccount *cursoraccount.Manager
// mu 表示当前声明中的 mu。
mu sync.RWMutex
@@ -84,8 +88,12 @@ func NewProxyService(proxy *mitm.ProxyServer, certManager *certs.Manager, caCert
publicClient: netproxy.NewHTTPClient(publicAPITimeout),
modelTestResults: make(map[string]ModelAdapterTestResult),
}
service.cursorAccount = cursoraccount.NewManager(
filepath.Join(appdata.DataRootPath(), "cursor-account.json"),
netproxy.NewHTTPClient(publicAPITimeout),
)
service.store = serverconfig.NewStore(service.configPath, service.logsRoot)
host, err := backend.NewHost(service.store)
host, err := backend.NewHost(service.store, service.cursorAccount)
if err != nil {
logger.Errorf("init backend host failed: %v", err)
} else {
@@ -101,7 +109,7 @@ func (s *ProxyService) ensureBackendHost() error {
if s.backendHost != nil {
return nil
}
host, err := backend.NewHost(s.store)
host, err := backend.NewHost(s.store, s.cursorAccount)
if err != nil {
return err
}
+589
View File
@@ -0,0 +1,589 @@
package cursoraccount
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"cursor/gen/aiserverv1"
"cursor/internal/backend/server/upstream"
"github.com/google/uuid"
"github.com/pkg/browser"
"google.golang.org/protobuf/proto"
)
const (
StateSignedOut = "signed_out"
StateWaiting = "waiting"
StateSignedIn = "signed_in"
StateError = "error"
websiteURL = "https://cursor.com"
backendURL = "https://api2.cursor.sh"
authClientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
loginTimeout = 10 * time.Minute
pollInterval = time.Second
refreshMargin = 2 * time.Minute
)
var ErrNotSignedIn = errors.New("尚未在 cursor-byok 中登录 Cursor 账号")
// Status 是可安全返回给前端的脱敏账号状态。
type Status struct {
State string `json:"state"`
AuthID string `json:"authId"`
Email string `json:"email"`
Error string `json:"error"`
}
type credentials struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
AuthID string `json:"authId"`
Email string `json:"email,omitempty"`
}
type pollResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
AuthID string `json:"authId"`
}
type refreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ShouldLogout bool `json:"shouldLogout"`
}
// Manager 持有 cursor-byok 自己的 Cursor 登录态,不读写 Cursor 客户端状态库。
type Manager struct {
path string
client *http.Client
mu sync.RWMutex
credentials credentials
state string
lastError string
loginCancel context.CancelFunc
loginGeneration uint64
refreshMu sync.Mutex
}
func NewManager(path string, client *http.Client) *Manager {
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
manager := &Manager{
path: strings.TrimSpace(path),
client: client,
state: StateSignedOut,
}
if err := manager.load(); err != nil {
manager.state = StateError
manager.lastError = fmt.Sprintf("读取 Cursor 账号凭据失败: %v", err)
}
return manager
}
func (manager *Manager) Status() Status {
if manager == nil {
return Status{State: StateSignedOut}
}
manager.mu.RLock()
defer manager.mu.RUnlock()
return Status{
State: manager.state,
AuthID: manager.credentials.AuthID,
Email: manager.credentials.Email,
Error: manager.lastError,
}
}
// EnsureEmail backfills a human-readable identity for credentials saved by
// builds that only persisted authId. Profile lookup failure does not invalidate
// an otherwise usable control-plane login.
func (manager *Manager) EnsureEmail(ctx context.Context) {
if manager == nil || !manager.SignedIn() {
return
}
current, generation := manager.snapshotCredentials()
if strings.TrimSpace(current.Email) != "" {
return
}
authorization, err := manager.Authorization(ctx)
if err != nil {
return
}
profile, err := manager.fetchProfile(ctx, authorization)
if err != nil || strings.TrimSpace(profile.GetEmail()) == "" {
return
}
current, currentGeneration := manager.snapshotCredentials()
if currentGeneration != generation {
return
}
current.Email = strings.TrimSpace(profile.GetEmail())
_ = manager.commitCredentials(generation, current)
}
func (manager *Manager) SignedIn() bool {
if manager == nil {
return false
}
manager.mu.RLock()
defer manager.mu.RUnlock()
return manager.state == StateSignedIn && strings.TrimSpace(manager.credentials.AccessToken) != ""
}
// StartLogin 启动官方浏览器 PKCE 登录,并在后台等待登录结果。
func (manager *Manager) StartLogin() (Status, error) {
if manager == nil {
return Status{State: StateError}, fmt.Errorf("Cursor 账号服务未初始化")
}
verifierBytes := make([]byte, 32)
if _, err := rand.Read(verifierBytes); err != nil {
return manager.Status(), fmt.Errorf("生成 Cursor 登录校验码失败: %w", err)
}
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
challengeBytes := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
loginID := uuid.NewString()
loginURL, err := buildLoginURL(loginID, challenge)
if err != nil {
return manager.Status(), err
}
ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
manager.mu.Lock()
if manager.loginCancel != nil {
manager.loginCancel()
}
manager.loginGeneration++
generation := manager.loginGeneration
manager.loginCancel = cancel
manager.state = StateWaiting
manager.lastError = ""
manager.mu.Unlock()
if err := browser.OpenURL(loginURL); err != nil {
cancel()
manager.finishWithError(generation, fmt.Sprintf("打开 Cursor 登录页面失败: %v", err))
return manager.Status(), err
}
go manager.pollLogin(ctx, generation, loginID, verifier)
return manager.Status(), nil
}
// Disconnect 只清除 cursor-byok 自己保存的账号,不调用 Cursor 客户端 logout。
func (manager *Manager) Disconnect() (Status, error) {
if manager == nil {
return Status{State: StateSignedOut}, nil
}
manager.mu.Lock()
manager.loginGeneration++
if manager.loginCancel != nil {
manager.loginCancel()
manager.loginCancel = nil
}
manager.credentials = credentials{}
manager.state = StateSignedOut
manager.lastError = ""
manager.mu.Unlock()
err := os.Remove(manager.path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
manager.mu.Lock()
manager.state = StateError
manager.lastError = fmt.Sprintf("清除 Cursor 账号凭据失败: %v", err)
manager.mu.Unlock()
return manager.Status(), err
}
return manager.Status(), nil
}
func (manager *Manager) Shutdown() {
if manager == nil {
return
}
manager.mu.Lock()
manager.loginGeneration++
if manager.loginCancel != nil {
manager.loginCancel()
manager.loginCancel = nil
}
manager.mu.Unlock()
}
// Authorization 返回官方控制面请求使用的真实 Cursor Bearer 身份。
func (manager *Manager) Authorization(ctx context.Context) (string, error) {
if manager == nil {
return "", ErrNotSignedIn
}
manager.refreshMu.Lock()
defer manager.refreshMu.Unlock()
creds, generation := manager.snapshotCredentials()
if strings.TrimSpace(creds.AccessToken) == "" {
return "", ErrNotSignedIn
}
if !tokenNeedsRefresh(creds.AccessToken, time.Now()) {
return bearer(creds.AccessToken), nil
}
if strings.TrimSpace(creds.RefreshToken) == "" {
manager.setAuthorizationError(generation, "Cursor 登录已过期,请重新登录")
return "", fmt.Errorf("Cursor 登录已过期且没有刷新令牌")
}
updated, shouldLogout, err := manager.refresh(ctx, creds)
if err != nil {
manager.setAuthorizationError(generation, fmt.Sprintf("刷新 Cursor 登录失败: %v", err))
return "", err
}
if shouldLogout {
manager.invalidateAuthorization(generation, "Cursor 登录已失效,请重新登录")
return "", ErrNotSignedIn
}
if err := manager.commitCredentials(generation, updated); err != nil {
return "", err
}
return bearer(updated.AccessToken), nil
}
func (manager *Manager) pollLogin(ctx context.Context, generation uint64, loginID string, verifier string) {
defer func() {
manager.mu.Lock()
if manager.loginGeneration == generation {
manager.loginCancel = nil
}
manager.mu.Unlock()
}()
for {
result, pending, err := manager.pollOnce(ctx, loginID, verifier)
if err == nil && !pending {
creds := credentials{
AccessToken: strings.TrimSpace(result.AccessToken),
RefreshToken: strings.TrimSpace(result.RefreshToken),
AuthID: strings.TrimSpace(result.AuthID),
}
if creds.AccessToken == "" {
manager.finishWithError(generation, "Cursor 登录响应缺少 access token")
return
}
if profile, profileErr := manager.fetchProfile(ctx, bearer(creds.AccessToken)); profileErr == nil {
creds.Email = strings.TrimSpace(profile.GetEmail())
}
_ = manager.commitCredentials(generation, creds)
return
}
if err != nil && !isRetryablePollError(err) {
manager.finishWithError(generation, fmt.Sprintf("Cursor 登录失败: %v", err))
return
}
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
manager.finishWithError(generation, "Cursor 登录等待超时,请重试")
}
return
case <-time.After(pollInterval):
}
}
}
func (manager *Manager) fetchProfile(ctx context.Context, authorization string) (*aiserverv1.GetMeResponse, error) {
body, err := proto.Marshal(&aiserverv1.GetMeRequest{})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/aiserver.v1.DashboardService/GetMe", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("authorization", authorization)
req.Header.Set("x-cursor-checksum", upstream.BuildCursorChecksum(authorization))
req.Header.Set("content-type", "application/proto")
req.Header.Set("accept", "application/proto")
req.Header.Set("connect-protocol-version", "1")
resp, err := manager.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("GetMe 返回 HTTP %d", resp.StatusCode)
}
profile := &aiserverv1.GetMeResponse{}
if err := proto.Unmarshal(responseBody, profile); err != nil {
return nil, err
}
return profile, nil
}
func (manager *Manager) pollOnce(ctx context.Context, loginID string, verifier string) (pollResponse, bool, error) {
endpoint, err := url.Parse(backendURL + "/auth/poll")
if err != nil {
return pollResponse{}, false, err
}
query := endpoint.Query()
query.Set("uuid", loginID)
query.Set("verifier", verifier)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return pollResponse{}, false, err
}
resp, err := manager.client.Do(req)
if err != nil {
return pollResponse{}, false, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
return pollResponse{}, true, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return pollResponse{}, false, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return pollResponse{}, false, fmt.Errorf("登录服务返回 HTTP %d", resp.StatusCode)
}
result := pollResponse{}
if err := json.Unmarshal(body, &result); err != nil {
return pollResponse{}, false, fmt.Errorf("解析登录响应失败: %w", err)
}
return result, false, nil
}
func (manager *Manager) refresh(ctx context.Context, current credentials) (credentials, bool, error) {
payload, err := json.Marshal(map[string]string{
"grant_type": "refresh_token",
"client_id": authClientID,
"refresh_token": current.RefreshToken,
})
if err != nil {
return credentials{}, false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/oauth/token", bytes.NewReader(payload))
if err != nil {
return credentials{}, false, err
}
req.Header.Set("content-type", "application/json")
resp, err := manager.client.Do(req)
if err != nil {
return credentials{}, false, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return credentials{}, false, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return credentials{}, false, fmt.Errorf("刷新服务返回 HTTP %d", resp.StatusCode)
}
result := refreshResponse{}
if err := json.Unmarshal(body, &result); err != nil {
return credentials{}, false, fmt.Errorf("解析刷新响应失败: %w", err)
}
if result.ShouldLogout {
return credentials{}, true, nil
}
if strings.TrimSpace(result.AccessToken) == "" {
return credentials{}, false, fmt.Errorf("刷新响应缺少 access token")
}
current.AccessToken = strings.TrimSpace(result.AccessToken)
if strings.TrimSpace(result.RefreshToken) != "" {
current.RefreshToken = strings.TrimSpace(result.RefreshToken)
}
return current, false, nil
}
func (manager *Manager) load() error {
if manager.path == "" {
return fmt.Errorf("Cursor 账号凭据路径为空")
}
data, err := os.ReadFile(manager.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
loaded := credentials{}
if err := json.Unmarshal(data, &loaded); err != nil {
return err
}
loaded.AccessToken = strings.TrimSpace(loaded.AccessToken)
loaded.RefreshToken = strings.TrimSpace(loaded.RefreshToken)
loaded.AuthID = strings.TrimSpace(loaded.AuthID)
loaded.Email = strings.TrimSpace(loaded.Email)
if loaded.AccessToken == "" {
return nil
}
manager.credentials = loaded
manager.state = StateSignedIn
return nil
}
func (manager *Manager) save(value credentials) error {
if manager.path == "" {
return fmt.Errorf("Cursor 账号凭据路径为空")
}
if err := os.MkdirAll(filepath.Dir(manager.path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
tempPath := manager.path + ".tmp"
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
}
if err := os.Chmod(tempPath, 0o600); err != nil {
_ = os.Remove(tempPath)
return err
}
if err := os.Rename(tempPath, manager.path); err != nil {
_ = os.Remove(tempPath)
return err
}
return os.Chmod(manager.path, 0o600)
}
func (manager *Manager) snapshotCredentials() (credentials, uint64) {
manager.mu.RLock()
defer manager.mu.RUnlock()
return manager.credentials, manager.loginGeneration
}
func (manager *Manager) finishWithError(generation uint64, message string) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.loginGeneration != generation {
return
}
manager.state = StateError
manager.lastError = strings.TrimSpace(message)
}
func (manager *Manager) commitCredentials(generation uint64, value credentials) error {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.loginGeneration != generation {
return ErrNotSignedIn
}
if err := manager.save(value); err != nil {
manager.state = StateError
manager.lastError = fmt.Sprintf("保存 Cursor 登录凭据失败: %v", err)
return err
}
manager.credentials = value
manager.state = StateSignedIn
manager.lastError = ""
return nil
}
func (manager *Manager) setAuthorizationError(generation uint64, message string) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.loginGeneration != generation {
return
}
manager.state = StateError
manager.lastError = strings.TrimSpace(message)
}
func (manager *Manager) invalidateAuthorization(generation uint64, message string) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.loginGeneration != generation {
return
}
manager.loginGeneration++
manager.credentials = credentials{}
manager.state = StateError
manager.lastError = strings.TrimSpace(message)
_ = os.Remove(manager.path)
}
func buildLoginURL(loginID string, challenge string) (string, error) {
parsed, err := url.Parse(websiteURL + "/loginDeepControl")
if err != nil {
return "", err
}
query := parsed.Query()
query.Set("challenge", challenge)
query.Set("uuid", loginID)
query.Set("mode", "login")
query.Set("supportsSelectedTeamLogin", "true")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func bearer(token string) string {
value := strings.TrimSpace(token)
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return value
}
return "Bearer " + value
}
func tokenNeedsRefresh(token string, now time.Time) bool {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
claims := struct {
ExpiresAt json.Number `json:"exp"`
}{}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.UseNumber()
if err := decoder.Decode(&claims); err != nil || claims.ExpiresAt == "" {
return false
}
expiresAt, err := claims.ExpiresAt.Int64()
if err != nil {
return false
}
return !now.Add(refreshMargin).Before(time.Unix(expiresAt, 0))
}
func isRetryablePollError(err error) bool {
if err == nil {
return false
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
return true
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "http 429") || strings.Contains(message, "http 5")
}