diff --git a/frontend/src/components/CursorAccountCard.vue b/frontend/src/components/CursorAccountCard.vue deleted file mode 100644 index a513c36..0000000 --- a/frontend/src/components/CursorAccountCard.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - diff --git a/frontend/src/i18n/generated/catalog.json b/frontend/src/i18n/generated/catalog.json index 6c0cb10..31493d4 100644 --- a/frontend/src/i18n/generated/catalog.json +++ b/frontend/src/i18n/generated/catalog.json @@ -250,18 +250,6 @@ } ] }, - "1c631615c1d85c9e": { - "source": "登录 Cursor", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 197, - "column": 14 - } - ] - }, "1e238093b79b3165": { "source": "留空时默认 65536", "kind": "text", @@ -313,12 +301,12 @@ }, { "file": "src/views/Home.vue", - "line": 118, + "line": 117, "column": 21 }, { "file": "src/views/Home.vue", - "line": 126, + "line": 125, "column": 21 } ] @@ -376,11 +364,6 @@ "kind": "text", "placeholders": 0, "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 98, - "column": 17 - }, { "file": "src/components/ModelAdapterModal.vue", "line": 289, @@ -587,28 +570,6 @@ } ] }, - "3ab8cc15939f3b5c": { - "source": "退出登录", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 95, - "column": 12 - }, - { - "file": "src/components/CursorAccountCard.vue", - "line": 97, - "column": 18 - }, - { - "file": "src/components/CursorAccountCard.vue", - "line": 188, - "column": 1 - } - ] - }, "3af7e5489e61ea51": { "source": "刷新中", "kind": "text", @@ -655,18 +616,6 @@ } ] }, - "3d52574ce1500561": { - "source": "未连接", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 61, - "column": 10 - } - ] - }, "3ea83f9f55062582": { "source": "发布时间:{0}", "kind": "template", @@ -893,18 +842,6 @@ } ] }, - "58c6b0935a7216da": { - "source": "打开贡献者主页失败", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 73, - "column": 21 - } - ] - }, "593a972852ba0004": { "source": "Cursor助手|永久免费|自定义API", "kind": "text", @@ -1001,12 +938,12 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 101, + "line": 100, "column": 21 }, { "file": "src/views/Home.vue", - "line": 111, + "line": 110, "column": 19 } ] @@ -1146,18 +1083,6 @@ } ] }, - "688102a402ba015a": { - "source": "等待登录...", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 197, - "column": 14 - } - ] - }, "6a7b96f399e58138": { "source": "例如:sk-xxxxxx", "kind": "text", @@ -1192,16 +1117,6 @@ "kind": "text", "placeholders": 0, "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 65, - "column": 34 - }, - { - "file": "src/components/CursorAccountCard.vue", - "line": 65, - "column": 52 - }, { "file": "src/state/appState.js", "line": 23, @@ -1219,12 +1134,12 @@ }, { "file": "src/views/Home.vue", - "line": 84, + "line": 83, "column": 34 }, { "file": "src/views/Home.vue", - "line": 84, + "line": 83, "column": 52 }, { @@ -1427,7 +1342,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 91, + "line": 90, "column": 21 } ] @@ -1504,23 +1419,11 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 108, + "line": 107, "column": 13 } ] }, - "83be9cac28873059": { - "source": "Cursor 控制面账号", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 135, - "column": 56 - } - ] - }, "83fcfb4c1f2c1641": { "source": "获取模型", "kind": "text", @@ -1620,7 +1523,7 @@ }, { "file": "src/views/Home.vue", - "line": 187, + "line": 184, "column": 68 } ] @@ -2088,7 +1991,7 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 183, + "line": 180, "column": 47 } ] @@ -2353,18 +2256,6 @@ } ] }, - "c3d46b387eeadb23": { - "source": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 96, - "column": 14 - } - ] - }, "c5af02060847d167": { "source": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "kind": "text", @@ -2396,23 +2287,11 @@ "refs": [ { "file": "src/views/Home.vue", - "line": 186, + "line": 183, "column": 63 } ] }, - "c8a52b66651d294c": { - "source": "退出登录失败", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 107, - "column": 21 - } - ] - }, "c8c14507b2d37395": { "source": "推理强度", "kind": "text", @@ -2507,18 +2386,6 @@ } ] }, - "cfa6c803eb3fc713": { - "source": "等待浏览器登录", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 60, - "column": 42 - } - ] - }, "d0325067fed88e5a": { "source": "缓存命中率 {0}", "kind": "template", @@ -2618,18 +2485,6 @@ } ] }, - "d6ce4f0f88178144": { - "source": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 170, - "column": 1 - } - ] - }, "d7889896c5b7732a": { "source": "Anthropic 额外参数 JSON", "kind": "text", @@ -2789,47 +2644,11 @@ }, { "file": "src/views/Home.vue", - "line": 182, + "line": 179, "column": 56 } ] }, - "e4343921c928a856": { - "source": "登录失败", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 86, - "column": 21 - } - ] - }, - "e4c0daa3c4bea691": { - "source": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 146, - "column": 20 - } - ] - }, - "e53580f8031f13c0": { - "source": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 173, - "column": 1 - } - ] - }, "e552c2accdbf5178": { "source": "新增模型", "kind": "text", @@ -2854,18 +2673,6 @@ } ] }, - "e8a0a6053998ebfa": { - "source": "已经登录", - "kind": "text", - "placeholders": 0, - "refs": [ - { - "file": "src/components/CursorAccountCard.vue", - "line": 59, - "column": 43 - } - ] - }, "eaffd48cd2ea9f1a": { "source": "例如:https://api.anthropic.com", "kind": "text", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index f3d9e8d..ee21b75 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -19,7 +19,6 @@ "1afed6a81a2512d2": "Select model", "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", @@ -40,11 +39,9 @@ "37d23612f78a2e63": "Restart Now to Update", "392d0dceb45998d3": "Extreme", "393df9bb13ea4900": "Hit", - "3ab8cc15939f3b5c": "Log out", "3af7e5489e61ea51": "Refreshing", "3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic", "3d13868593ae4eeb": "Interface Language", - "3d52574ce1500561": "Not connected", "3ea83f9f55062582": "Release date: {0}", "3edda85621fd03b2": "model adapters", "3fd47edce45b3603": "Close", @@ -63,7 +60,6 @@ "5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.", "54e6745ff43c9c74": "Sorting failed", "56627c94a9decee6": "Max Output Tokens", - "58c6b0935a7216da": "Failed to open contributor profile", "593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API", "59a2195a01a8b35b": "{0} must be a valid JSON object", "5aa8f5590c940829": "Non-cache Input: {0}", @@ -82,7 +78,6 @@ "66af574b8948fe83": "{0} API key cannot be empty", "6744b4c6a9aa0038": "Disabled", "675109292da4eb36": "Not tested yet", - "688102a402ba015a": "Waiting for login...", "6a7b96f399e58138": "e.g. sk-xxxxxx", "6aa8f49cc992dfd7": "Test", "6ae23d6d7cb18592": "Service error", @@ -106,7 +101,6 @@ "8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.", "8151e8704a7ca89e": "No matches", "83913e71fcf7ff60": "Refresh successful", - "83be9cac28873059": "Cursor Control Plane Account", "83fcfb4c1f2c1641": "Fetch Models", "8672864e90417138": "Max", "86df7ec743047234": "Service running", @@ -168,11 +162,9 @@ "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?", "c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.", "c6868592796ac2b2": "No {0} models have been configured yet.", "c69f5bce63b9f14c": "Settings Folder", - "c8a52b66651d294c": "Failed to log out", "c8c14507b2d37395": "Reasoning Effort", "c98e118e0a43f078": "Model", "c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)", @@ -180,7 +172,6 @@ "ca1d1059408b3837": "Invalid turns: {0}", "cc5049729a2c10f1": "Test failed. Check the raw details.", "cd7ca5fb221e1c53": "{0} cannot be empty", - "cfa6c803eb3fc713": "Waiting for browser login", "d0325067fed88e5a": "Cache hit rate {0}", "d20ab96566d33f25": "{0} display name cannot be empty", "d2243e1d44b2a94e": "Edit Model Settings", @@ -188,7 +179,6 @@ "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", @@ -201,12 +191,8 @@ "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", - "e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.", - "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}", diff --git a/frontend/src/i18n/locales/ja-JP.json b/frontend/src/i18n/locales/ja-JP.json index 5c01f98..7d8b04c 100644 --- a/frontend/src/i18n/locales/ja-JP.json +++ b/frontend/src/i18n/locales/ja-JP.json @@ -19,7 +19,6 @@ "1afed6a81a2512d2": "モデルを選択", "1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています", "1bc77f5ab979f4c1": "モデル設定を追加", - "1c631615c1d85c9e": "Cursor にログイン", "1e238093b79b3165": "空欄で 65536", "21296ab18ad9af25": "追加パラメータ JSON", "24343a2096988d42": "開けませんでした", @@ -40,11 +39,9 @@ "37d23612f78a2e63": "今すぐ再起動して更新", "392d0dceb45998d3": "最高", "393df9bb13ea4900": "ヒット", - "3ab8cc15939f3b5c": "ログアウト", "3af7e5489e61ea51": "更新中", "3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします", "3d13868593ae4eeb": "表示言語", - "3d52574ce1500561": "未接続", "3ea83f9f55062582": "公開日時: {0}", "3edda85621fd03b2": "件のモデルアダプター", "3fd47edce45b3603": "閉じる", @@ -63,7 +60,6 @@ "5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。", "54e6745ff43c9c74": "並べ替えに失敗しました", "56627c94a9decee6": "最大出力 Token", - "58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした", "593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API", "59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります", "5aa8f5590c940829": "非キャッシュ入力:{0}", @@ -82,7 +78,6 @@ "66af574b8948fe83": "{0} の API キーは必須です", "6744b4c6a9aa0038": "無効化", "675109292da4eb36": "まだテストしていません", - "688102a402ba015a": "ログインを待っています...", "6a7b96f399e58138": "例: sk-xxxxxx", "6aa8f49cc992dfd7": "テスト", "6ae23d6d7cb18592": "サービスエラー", @@ -106,7 +101,6 @@ "8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。", "8151e8704a7ca89e": "一致する項目がありません", "83913e71fcf7ff60": "更新しました", - "83be9cac28873059": "Cursor コントロールプレーンアカウント", "83fcfb4c1f2c1641": "モデルを取得", "8672864e90417138": "最大", "86df7ec743047234": "サービス稼働中", @@ -168,11 +162,9 @@ "bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます", "bef280f9eb392495": "会話ターン", "c228558cf257fc49": "削除に失敗しました", - "c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?", "c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。", "c6868592796ac2b2": "まだ {0} モデルが設定されていません。", "c69f5bce63b9f14c": "設定フォルダー", - "c8a52b66651d294c": "ログアウトに失敗しました", "c8c14507b2d37395": "推論強度", "c98e118e0a43f078": "モデル", "c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)", @@ -180,7 +172,6 @@ "ca1d1059408b3837": "異常ターン: {0}", "cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。", "cd7ca5fb221e1c53": "{0}は空にできません", - "cfa6c803eb3fc713": "ブラウザでのログインを待っています", "d0325067fed88e5a": "キャッシュヒット率 {0}", "d20ab96566d33f25": "{0} の表示名は必須です", "d2243e1d44b2a94e": "モデル設定を編集", @@ -188,7 +179,6 @@ "d373809ab86ba93b": "コピー", "d3b1da3088ddd334": "モデルテストに失敗しました", "d53d32f1a1211371": "カスタムヘッダー JSON", - "d6ce4f0f88178144": "プラグイン、Skills、MCP 専用です。Cursor クライアントの現在のアカウントは変更しません", "d7889896c5b7732a": "Anthropic 追加パラメータ JSON", "d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)", "d95e5cb6bdcee553": "キャッシュ作成を含める", @@ -201,12 +191,8 @@ "e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。", "e14c41ef2b7253c9": "総リクエスト Token: {0}", "e406825e0a72d2c2": "ローカル設定", - "e4343921c928a856": "ログインに失敗しました", - "e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。", - "e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください", "e552c2accdbf5178": "モデルを追加", "e6faccfddce722e8": "キャッシュ読込 Token: {0}", - "e8a0a6053998ebfa": "ログイン済み", "eaffd48cd2ea9f1a": "例: https://api.anthropic.com", "eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。", "ec3b17a75db49e24": "{0} t/s | 初回 Token {1}", diff --git a/frontend/src/i18n/locales/ru-RU.json b/frontend/src/i18n/locales/ru-RU.json index 80f0ed5..cb8864f 100644 --- a/frontend/src/i18n/locales/ru-RU.json +++ b/frontend/src/i18n/locales/ru-RU.json @@ -19,7 +19,6 @@ "1afed6a81a2512d2": "Выберите модель", "1baddde657dd2720": "Исходящие запросы используют системный прокси", "1bc77f5ab979f4c1": "Добавить настройки модели", - "1c631615c1d85c9e": "Войти в Cursor", "1e238093b79b3165": "Если оставить пустым, используется 65536", "21296ab18ad9af25": "Дополнительные параметры JSON", "24343a2096988d42": "Не удалось открыть", @@ -40,11 +39,9 @@ "37d23612f78a2e63": "Перезапустить и обновить", "392d0dceb45998d3": "Очень высокая", "393df9bb13ea4900": "Попадание", - "3ab8cc15939f3b5c": "Выйти", "3af7e5489e61ea51": "Обновление", "3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic", "3d13868593ae4eeb": "Язык интерфейса", - "3d52574ce1500561": "Не подключено", "3ea83f9f55062582": "Дата выпуска: {0}", "3edda85621fd03b2": "адаптеров моделей", "3fd47edce45b3603": "Закрыть", @@ -63,7 +60,6 @@ "5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.", "54e6745ff43c9c74": "Не удалось изменить порядок", "56627c94a9decee6": "Макс. выходных токенов", - "58c6b0935a7216da": "Не удалось открыть профиль участника", "593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API", "59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON", "5aa8f5590c940829": "Ввод без кеша: {0}", @@ -82,7 +78,6 @@ "66af574b8948fe83": "Ключ API {0} не может быть пустым", "6744b4c6a9aa0038": "Выключено", "675109292da4eb36": "Еще не проверено", - "688102a402ba015a": "Ожидание входа...", "6a7b96f399e58138": "например, sk-xxxxxx", "6aa8f49cc992dfd7": "Проверить", "6ae23d6d7cb18592": "Ошибка сервиса", @@ -106,7 +101,6 @@ "8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.", "8151e8704a7ca89e": "Совпадений нет", "83913e71fcf7ff60": "Обновление выполнено", - "83be9cac28873059": "Аккаунт управляющего уровня Cursor", "83fcfb4c1f2c1641": "Получить модели", "8672864e90417138": "Максимальная", "86df7ec743047234": "Сервис запущен", @@ -168,11 +162,9 @@ "bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение", "bef280f9eb392495": "Ходы диалога", "c228558cf257fc49": "Не удалось удалить", - "c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?", "c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.", "c6868592796ac2b2": "Модели {0} пока не настроены.", "c69f5bce63b9f14c": "Папка настроек", - "c8a52b66651d294c": "Не удалось выйти", "c8c14507b2d37395": "Интенсивность рассуждений", "c98e118e0a43f078": "Модель", "c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)", @@ -180,7 +172,6 @@ "ca1d1059408b3837": "Ошибочных ходов: {0}", "cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.", "cd7ca5fb221e1c53": "{0} не может быть пустым", - "cfa6c803eb3fc713": "Ожидание входа в браузере", "d0325067fed88e5a": "Доля попаданий в кеш: {0}", "d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым", "d2243e1d44b2a94e": "Изменить настройки модели", @@ -188,7 +179,6 @@ "d373809ab86ba93b": "Копировать", "d3b1da3088ddd334": "Проверка модели не пройдена", "d53d32f1a1211371": "Пользовательские заголовки JSON", - "d6ce4f0f88178144": "Используется только для Plugins, Skills и MCP; текущий аккаунт клиента Cursor не изменяется", "d7889896c5b7732a": "Дополнительные параметры Anthropic JSON", "d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)", "d95e5cb6bdcee553": "Учитывать создание кеша", @@ -201,12 +191,8 @@ "e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.", "e14c41ef2b7253c9": "Всего токенов запроса: {0}", "e406825e0a72d2c2": "Локальные настройки", - "e4343921c928a856": "Не удалось войти", - "e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.", - "e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов", "e552c2accdbf5178": "Добавить модель", "e6faccfddce722e8": "Токены чтения из кеша: {0}", - "e8a0a6053998ebfa": "Выполнен вход", "eaffd48cd2ea9f1a": "например, https://api.anthropic.com", "eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.", "ec3b17a75db49e24": "{0} т/с | Первый токен {1}", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 8b7eb56..7268898 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -19,7 +19,6 @@ "1afed6a81a2512d2": "选择模型", "1baddde657dd2720": "当前出站请求使用系统代理", "1bc77f5ab979f4c1": "新增模型配置", - "1c631615c1d85c9e": "登录 Cursor", "1e238093b79b3165": "留空时默认 65536", "21296ab18ad9af25": "额外参数 JSON", "24343a2096988d42": "打开失败", @@ -40,11 +39,9 @@ "37d23612f78a2e63": "立即重启更新", "392d0dceb45998d3": "极高", "393df9bb13ea4900": "命中", - "3ab8cc15939f3b5c": "退出登录", "3af7e5489e61ea51": "刷新中", "3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic", "3d13868593ae4eeb": "界面语言", - "3d52574ce1500561": "未连接", "3ea83f9f55062582": "发布时间:{0}", "3edda85621fd03b2": "个模型适配器", "3fd47edce45b3603": "关闭", @@ -63,7 +60,6 @@ "5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。", "54e6745ff43c9c74": "排序失败", "56627c94a9decee6": "最大输出 Token", - "58c6b0935a7216da": "打开贡献者主页失败", "593a972852ba0004": "Cursor助手|永久免费|自定义API", "59a2195a01a8b35b": "{0}必须是合法 JSON 对象", "5aa8f5590c940829": "非缓存输入:{0}", @@ -82,7 +78,6 @@ "66af574b8948fe83": "{0} 的访问密钥不能为空", "6744b4c6a9aa0038": "已关闭", "675109292da4eb36": "尚未测试", - "688102a402ba015a": "等待登录...", "6a7b96f399e58138": "例如:sk-xxxxxx", "6aa8f49cc992dfd7": "测试", "6ae23d6d7cb18592": "服务错误", @@ -106,7 +101,6 @@ "8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", "8151e8704a7ca89e": "没有匹配项", "83913e71fcf7ff60": "刷新成功", - "83be9cac28873059": "Cursor 控制面账号", "83fcfb4c1f2c1641": "获取模型", "8672864e90417138": "最高", "86df7ec743047234": "服务运行中", @@ -168,11 +162,9 @@ "bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理", "bef280f9eb392495": "对话轮次", "c228558cf257fc49": "删除失败", - "c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?", "c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "c6868592796ac2b2": "当前还没有配置任何 {0} 模型。", "c69f5bce63b9f14c": "设置文件夹", - "c8a52b66651d294c": "退出登录失败", "c8c14507b2d37395": "推理强度", "c98e118e0a43f078": "模型", "c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)", @@ -180,7 +172,6 @@ "ca1d1059408b3837": "异常轮次:{0}", "cc5049729a2c10f1": "测试失败,请查看原始信息", "cd7ca5fb221e1c53": "{0}不能为空", - "cfa6c803eb3fc713": "等待浏览器登录", "d0325067fed88e5a": "缓存命中率 {0}", "d20ab96566d33f25": "{0} 的显示名称不能为空", "d2243e1d44b2a94e": "编辑模型配置", @@ -188,7 +179,6 @@ "d373809ab86ba93b": "拷贝", "d3b1da3088ddd334": "模型测试失败", "d53d32f1a1211371": "自定义请求头 JSON", - "d6ce4f0f88178144": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号", "d7889896c5b7732a": "Anthropic 额外参数 JSON", "d7da2aabd35772ec": "例如:200000(留空用默认值)", "d95e5cb6bdcee553": "计入缓存创建", @@ -201,12 +191,8 @@ "e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。", "e14c41ef2b7253c9": "总请求:{0}", "e406825e0a72d2c2": "本地配置", - "e4343921c928a856": "登录失败", - "e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。", - "e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场", "e552c2accdbf5178": "新增模型", "e6faccfddce722e8": "缓存读取:{0}", - "e8a0a6053998ebfa": "已经登录", "eaffd48cd2ea9f1a": "例如:https://api.anthropic.com", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", "ec3b17a75db49e24": "{0} t/s | 首字 {1}", diff --git a/frontend/src/services/clientApi.js b/frontend/src/services/clientApi.js index ba077bb..1fe2016 100644 --- a/frontend/src/services/clientApi.js +++ b/frontend/src/services/clientApi.js @@ -1,10 +1,7 @@ import { - DisconnectCursorAccount, - GetCursorAccountStatus, GetState, LoadUserConfig, SaveUserConfig, - StartCursorAccountLogin, StartProxy, StopProxy, } from "@bindings/cursor/internal/bridge/proxyservice.js"; @@ -63,18 +60,6 @@ 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()); } diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 90332ff..4fcc45b 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -2,7 +2,6 @@ import Button from "@/components/ui/Button.vue"; import Card from "@/components/ui/Card.vue"; import HomeMetricsCard from "@/components/HomeMetricsCard.vue"; -import CursorAccountCard from "@/components/CursorAccountCard.vue"; import { useMessage } from "@/composables/useMessage"; import { getAdRuntime } from "@/services/clientApi"; import { @@ -174,8 +173,6 @@ onBeforeUnmount(() => { - -
diff --git a/internal/app/runner.go b/internal/app/runner.go index 159790f..97718de 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -78,7 +78,7 @@ func Run(resources EmbeddedResources) error { return err } - defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr + defaultBackendBaseURL := browserReachableLoopbackBaseURL(serverconfig.DefaultBackendListenAddr) proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager) if err != nil { return err @@ -433,13 +433,16 @@ func windowsAdditionalBrowserArgs() []string { func browserReachableLoopbackBaseURL(listenAddr string) string { host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr)) if err != nil || strings.TrimSpace(port) == "" { - return "http://" + serverconfig.DefaultBackendListenAddr + return "https://localhost:8000" } host = strings.TrimSpace(host) if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" { host = "127.0.0.1" } - return "http://" + net.JoinHostPort(host, port) + if host == "127.0.0.1" || host == "::1" || host == "localhost" { + host = "localhost" + } + return "https://" + net.JoinHostPort(host, port) } // logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。 diff --git a/internal/backend/forwarder/ai_handler.go b/internal/backend/forwarder/ai_handler.go index b7ba836..817dfa6 100644 --- a/internal/backend/forwarder/ai_handler.go +++ b/internal/backend/forwarder/ai_handler.go @@ -19,87 +19,119 @@ type usageLookupRecord struct { CreatedAt time.Time } +type aiHandler struct { + mux *http.ServeMux + paths map[string]struct{} +} + +func newAIHandlerMux() *aiHandler { + return &aiHandler{ + mux: http.NewServeMux(), + paths: make(map[string]struct{}), + } +} + +func (handler *aiHandler) Handle(pattern string, target http.Handler) { + handler.paths[pattern] = struct{}{} + handler.mux.Handle(pattern, target) +} + +func (handler *aiHandler) HandlesPath(path string) bool { + if handler == nil { + return false + } + _, ok := handler.paths[path] + return ok +} + +func (handler *aiHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if handler == nil || handler.mux == nil { + http.NotFound(writer, request) + return + } + handler.mux.ServeHTTP(writer, request) +} + const ( dashboardServiceGetTokenUsageProcedure = "/aiserver.v1.DashboardService/GetTokenUsage" dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure = "/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment" ) -func newAIHandler(service *Service) http.Handler { - mux := http.NewServeMux() - mux.Handle( +func newAIHandler(service *Service) *aiHandler { + handler := newAIHandlerMux() + handler.Handle( dashboardServiceGetTokenUsageProcedure, connect.NewUnaryHandler(dashboardServiceGetTokenUsageProcedure, service.GetTokenUsage), ) - mux.Handle( + handler.Handle( dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, connect.NewUnaryHandler(dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, service.GetGlassEarlyPreviewEnrollment), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceCountTokensProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceCountTokensProcedure, service.CountTokens), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, service.GetThoughtAnnotation), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, service.WriteGitCommitMessage), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, service.CreateExperimentalIndex), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, service.ListExperimentalIndexFiles), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceListenExperimentalIndexProcedure, connect.NewServerStreamHandler(aiserverv1connect.AiServiceListenExperimentalIndexProcedure, service.ListenExperimentalIndex), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceRegisterFileToIndexProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceRegisterFileToIndexProcedure, service.RegisterFileToIndex), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, service.SetupIndexDependencies), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, service.ComputeIndexTopoSort), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceDocumentationQueryProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceDocumentationQueryProcedure, service.DocumentationQuery), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceAvailableDocsProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceAvailableDocsProcedure, service.AvailableDocs), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, service.KnowledgeBaseAdd), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceKnowledgeBaseListProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseListProcedure, service.KnowledgeBaseList), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, service.KnowledgeBaseRemove), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, service.KnowledgeBaseUpdate), ) - mux.Handle( + handler.Handle( aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, connect.NewUnaryHandler(aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, service.FetchRelevantKnowledgeForConversation), ) - mux.Handle("/", http.NotFoundHandler()) - return mux + return handler } func (service *Service) GetThoughtAnnotation(_ context.Context, req *connect.Request[aiserverv1.GetThoughtAnnotationRequest]) (*connect.Response[aiserverv1.GetThoughtAnnotationResponse], error) { diff --git a/internal/backend/forwarder/ai_handler_routes_test.go b/internal/backend/forwarder/ai_handler_routes_test.go new file mode 100644 index 0000000..5059675 --- /dev/null +++ b/internal/backend/forwarder/ai_handler_routes_test.go @@ -0,0 +1,20 @@ +package forwarder + +import ( + "testing" + + "cursor/gen/aiserverv1/aiserverv1connect" +) + +func TestAIHandlerTracksLocallyImplementedPaths(t *testing.T) { + handler := newAIHandler(&Service{}) + if !handler.HandlesPath(aiserverv1connect.AiServiceCountTokensProcedure) { + t.Fatalf("expected %q to be handled locally", aiserverv1connect.AiServiceCountTokensProcedure) + } + if !handler.HandlesPath(dashboardServiceGetTokenUsageProcedure) { + t.Fatalf("expected %q to be handled locally", dashboardServiceGetTokenUsageProcedure) + } + if handler.HandlesPath("/aiserver.v1.AiService/UnknownProcedure") { + t.Fatal("unknown AI procedure must fall through to upstream") + } +} diff --git a/internal/backend/forwarder/module.go b/internal/backend/forwarder/module.go index edc6078..d30ef13 100644 --- a/internal/backend/forwarder/module.go +++ b/internal/backend/forwarder/module.go @@ -32,3 +32,11 @@ func NewModule(historyRoot string, channelService modeladapter.ChannelResolver) UploadServiceHandler: newUploadServiceHandler(service), } } + +func (module *Module) HandlesAIPath(path string) bool { + if module == nil || module.AiHandler == nil { + return false + } + handler, ok := module.AiHandler.(interface{ HandlesPath(string) bool }) + return ok && handler.HandlesPath(path) +} diff --git a/internal/backend/host.go b/internal/backend/host.go index 3c84d53..aaf901a 100644 --- a/internal/backend/host.go +++ b/internal/backend/host.go @@ -2,6 +2,8 @@ package backend import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "net" "net/http" @@ -27,11 +29,11 @@ const healthPath = "/healthz" const tabServerBaseURL = "https://tab.leokun.cn" type Host struct { - store *serverconfig.Store - listenAddr string - configs *serverconfig.Manager - healthHTTP *http.Client - controlPlaneAuth upstream.AuthorizationProvider + store *serverconfig.Store + listenAddr string + configs *serverconfig.Manager + healthHTTP *http.Client + tlsCertificate *tls.Certificate runMu sync.RWMutex httpServer *http.Server @@ -41,7 +43,20 @@ type Host struct { mux http.Handler } -func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationProvider) (*Host, error) { +type HostOption func(*Host) error + +func WithTLSCertificate(certificate *tls.Certificate) HostOption { + return func(host *Host) error { + if certificate == nil || len(certificate.Certificate) == 0 || certificate.PrivateKey == nil { + return fmt.Errorf("backend TLS certificate is invalid") + } + copied := *certificate + host.tlsCertificate = &copied + return nil + } +} + +func NewHost(store *serverconfig.Store, options ...HostOption) (*Host, error) { if store == nil { return nil, fmt.Errorf("backend config store is required") } @@ -51,12 +66,19 @@ func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationP } cfg := configs.Current() host := &Host{ - store: store, - listenAddr: cfg.BackendListenAddr, - configs: configs, - healthHTTP: newLoopbackHTTPClient(), - controlPlaneAuth: controlPlaneAuth, + store: store, + listenAddr: cfg.BackendListenAddr, + configs: configs, } + for _, option := range options { + if option == nil { + continue + } + if err := option(host); err != nil { + return nil, err + } + } + host.healthHTTP = newLoopbackHTTPClient(host.tlsCertificate) if err := host.rebuild(cfg); err != nil { return nil, err } @@ -107,7 +129,14 @@ func (host *Host) BaseURL() string { if listenAddr == "" { return "" } - return "http://" + listenAddr + if host.tlsCertificate == nil { + return "http://" + listenAddr + } + serverName := "localhost" + if _, port, err := net.SplitHostPort(listenAddr); err == nil { + return "https://" + net.JoinHostPort(serverName, port) + } + return "https://" + listenAddr } func (host *Host) IsRunning() bool { @@ -153,6 +182,12 @@ func (host *Host) Start() error { host.lastRunErr = fmt.Errorf("监听内置后端 %s 失败: %w", host.listenAddr, err) return host.lastRunErr } + if host.tlsCertificate != nil { + listener = tls.NewListener(listener, &tls.Config{ + Certificates: []tls.Certificate{*host.tlsCertificate}, + MinVersion: tls.VersionTLS12, + }) + } host.listenAddr = listener.Addr().String() host.httpServer = httpServer host.lastRunErr = nil @@ -202,7 +237,7 @@ func (host *Host) HealthCheck(ctx context.Context) error { } client := host.healthHTTP if client == nil { - client = newLoopbackHTTPClient() + client = newLoopbackHTTPClient(host.tlsCertificate) } response, err := client.Do(request) if err != nil { @@ -245,19 +280,34 @@ func (host *Host) InProcessHealthCheck() error { return nil } -func newLoopbackHTTPClient() *http.Client { +func newLoopbackHTTPClient(certificate *tls.Certificate) *http.Client { + transport := &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{ + Timeout: 1 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: false, + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: 30 * time.Second, + } + if certificate != nil { + roots := x509.NewCertPool() + for _, rawCertificate := range certificate.Certificate[1:] { + parsed, err := x509.ParseCertificate(rawCertificate) + if err == nil { + roots.AddCert(parsed) + } + } + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + ServerName: "localhost", + } + } return &http.Client{ - Transport: &http.Transport{ - Proxy: nil, - DialContext: (&net.Dialer{ - Timeout: 1 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - ForceAttemptHTTP2: false, - MaxIdleConns: 1, - MaxIdleConnsPerHost: 1, - IdleConnTimeout: 30 * time.Second, - }, + Transport: transport, } } @@ -276,8 +326,20 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { SystemSettingService: &serverSystemSettings{configs: host.configs}, HTTPClient: netproxy.NewHTTPClient(30000 * time.Second), } + fallbackForward := upstream.FallbackForwardAction( + routeDeps, + upstream.CompatRouteConfig{Name: "upstream_fallback"}, + upstream.DefaultCursorUpstreamBaseURL, + ) + localAIAction := server.HTTPHandlerAction(agentModule.AiHandler) + aiServiceAction := func(ctx *server.Context) error { + if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil && agentModule.HandlesAIPath(ctx.Request.URL.Path) { + return localAIAction(ctx) + } + return fallbackForward(ctx) + } - host.mux = server.New( + host.mux = withLocalBackendCORS(server.New( server.Use( server.Recover(), server.ServerContext(), @@ -427,19 +489,11 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { StatusCode: http.StatusOK, })), ), - server.POST("/oauth/token", - server.Name("oauth_token"), + server.GET("/auth/cursor_dev_session_token", + server.Name("auth_cursor_dev_session_token"), server.HTTP(), - server.Local(upstream.MockOAuthAction(routeDeps, upstream.CompatRouteConfig{ - Name: "oauth_token", - StatusCode: http.StatusOK, - })), - ), - server.POST("/aiserver.v1.AuthService/GetEmail", - server.Name("auth_service_get_email"), - server.ConnectUnary(), - server.Local(upstream.MockAuthEmailAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_service_get_email", + server.Local(upstream.MockDevSessionTokenAction(routeDeps, upstream.CompatRouteConfig{ + Name: "auth_cursor_dev_session_token", StatusCode: http.StatusOK, })), ), @@ -476,17 +530,14 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { server.Any("/aiserver.v1.AiService/*", server.Name("ai_service"), server.HTTP(), - server.Local(server.HTTPHandlerAction(agentModule.AiHandler)), + server.Local(aiServiceAction), ), tabServerProcedure("/aiserver.v1.CppService/AvailableModels", "cpp_available_models", server.ConnectUnary(), routeDeps), tabServerProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps), server.Any("/aiserver.v1.CppService/*", server.Name("cpp_service"), server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), + server.Local(fallbackForward), ), tabServerProcedure("/aiserver.v1.FileSyncService/FSSyncFile", "file_sync_sync_file", server.ConnectUnary(), routeDeps), tabServerProcedure("/aiserver.v1.FileSyncService/FSIsEnabledForUser", "file_sync_is_enabled_for_user", server.ConnectUnary(), routeDeps), @@ -495,10 +546,7 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { server.Any("/aiserver.v1.FileSyncService/*", server.Name("file_sync"), server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), + server.Local(fallbackForward), ), server.POST("/aiserver.v1.DashboardService/GetTokenUsage", server.Name("dashboard_token_usage"), @@ -530,21 +578,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.DashboardTeamsMockBuilder, })), ), - server.POST("/aiserver.v1.DashboardService/GetManagedSkills", - server.Name("dashboard_get_managed_skills"), - server.ConnectUnary(), - 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.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam", server.Name("dashboard_get_team_admin_settings_or_empty"), server.ConnectUnary(), @@ -565,76 +598,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.EmptyMockBuilder, })), ), - server.POST("/aiserver.v1.DashboardService/ListMarketplaces", - server.Name("dashboard_list_marketplaces"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_list_marketplaces", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.ListMarketplacesResponse", - MockBuilder: upstream.EmptyMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/GetGlobalCommands", - server.Name("dashboard_get_global_commands"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_get_global_commands", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.GetGlobalCommandsResponse", - MockBuilder: upstream.EmptyMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", - server.Name("dashboard_get_effective_user_plugins"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_get_effective_user_plugins", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.GetEffectiveUserPluginsResponse", - MockBuilder: upstream.EmptyMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins", - server.Name("dashboard_register_marketplace_and_plugins"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_register_marketplace_and_plugins", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.RegisterMarketplaceAndPluginsResponse", - MockBuilder: upstream.EmptyMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/GetCliDownloadUrl", - server.Name("dashboard_get_cli_download_url"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_get_cli_download_url", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.GetCliDownloadUrlResponse", - MockBuilder: upstream.EmptyMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/GetMe", - server.Name("dashboard_get_me"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_get_me", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.GetMeResponse", - MockBuilder: upstream.DashboardGetMeMockBuilder, - })), - ), - server.POST("/aiserver.v1.DashboardService/GetUserPrivacyMode", - server.Name("dashboard_user_privacy_mode"), - server.ConnectUnary(), - server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{ - Name: "dashboard_user_privacy_mode", - StatusCode: http.StatusOK, - MockProtoType: "aiserver.v1.GetUserPrivacyModeResponse", - MockBuilder: upstream.DashboardUserPrivacyModeMockBuilder, - })), - ), server.POST("/aiserver.v1.DashboardService/GetPlanInfo", server.Name("dashboard_plan_info"), server.ConnectUnary(), @@ -665,104 +628,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error { MockBuilder: upstream.DashboardIsOnNewPricingMockBuilder, })), ), - 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.Any("/*", + server.Name("upstream_fallback"), server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), + server.Local(fallbackForward), ), - server.Any("/aiserver.v1.NetworkService/*", - server.Name("network_service"), - server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), - ), - server.Any("/aiserver.v1.InAppAdService/*", - server.Name("in_app_ad"), - server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), - ), - server.GET("/auth/full_stripe_profile", - server.Name("auth_full_stripe_profile"), - server.HTTP(), - server.Local(upstream.MockAuthFullStripeProfileAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_full_stripe_profile", - StatusCode: http.StatusOK, - })), - ), - server.GET("/auth/stripe_profile", - server.Name("auth_stripe_profile"), - server.HTTP(), - server.Local(upstream.MockAuthStripeProfileAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_stripe_profile", - StatusCode: http.StatusOK, - })), - ), - server.GET("/auth/has_valid_payment_method", - server.Name("auth_has_valid_payment_method"), - server.HTTP(), - server.Local(upstream.MockJSONAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_has_valid_payment_method", - StatusCode: http.StatusOK, - JSONBody: map[string]any{ - "hasValidPaymentMethod": true, - }, - })), - ), - server.Any("/auth/poll", - server.Name("auth_poll"), - server.HTTP(), - server.Local(upstream.MockAuthPollAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_poll", - StatusCode: http.StatusOK, - })), - ), - server.POST("/auth/logout", - server.Name("auth_logout"), - server.HTTP(), - server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{ - Name: "auth_logout", - StatusCode: http.StatusNoContent, - })), - ), - server.Any("/auth/*", - server.Name("auth_proxy"), - server.HTTP(), - server.Local(func(ctx *server.Context) error { - http.NotFound(ctx.Writer, ctx.Request) - return nil - }), - ), - ) + )) return nil } +func withLocalBackendCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Access-Control-Allow-Origin", "*") + writer.Header().Del("Access-Control-Allow-Credentials") + if strings.EqualFold(request.Method, http.MethodOptions) && strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")) != "" { + writer.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + requestedHeaders := strings.TrimSpace(request.Header.Get("Access-Control-Request-Headers")) + if requestedHeaders == "" { + requestedHeaders = "authorization,content-type,x-cursor-client-type" + } + writer.Header().Set("Access-Control-Allow-Headers", requestedHeaders) + writer.Header().Set("Access-Control-Max-Age", "86400") + writer.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(writer, request) + }) +} + func repositoryServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module) server.Option { localAction := server.HTTPHandlerAction(module.RepositoryServiceHandler) return server.POST(pattern, @@ -803,46 +698,6 @@ func tabServerProcedure(pattern string, name string, protocol server.RouteOption ) } -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)), - ) -} - -func cursorControlPlaneAction( - authorizationProvider upstream.AuthorizationProvider, - deps upstream.Dependencies, - name string, - fallback server.HandlerFunc, -) server.HandlerFunc { - forward := upstream.AuthenticatedForwardAction(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 forward(ctx) - } -} - type serverSystemSettings struct { configs *serverconfig.Manager } diff --git a/internal/backend/host_auth_test.go b/internal/backend/host_auth_test.go new file mode 100644 index 0000000..2ba9acb --- /dev/null +++ b/internal/backend/host_auth_test.go @@ -0,0 +1,175 @@ +package backend + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "cursor/gen/aiserverv1" + serverconfig "cursor/internal/backend/server/config" + "cursor/internal/certs" + + "google.golang.org/protobuf/proto" +) + +func TestHostServesDevLoginAndLocalTeamsRoute(t *testing.T) { + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + host, err := NewHost(store) + if err != nil { + t.Fatalf("new host: %v", err) + } + + loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=enterprise&email=enterprise%40example.com", nil) + loginRecorder := httptest.NewRecorder() + host.mux.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("dev login status: got %d, want %d; body=%s", loginRecorder.Code, http.StatusOK, loginRecorder.Body.String()) + } + var loginResponse struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil { + t.Fatalf("decode dev login: %v", err) + } + if loginResponse.AccessToken == "" { + t.Fatal("dev login returned an empty access token") + } + + teamsRequest := httptest.NewRequest(http.MethodPost, "http://local/aiserver.v1.DashboardService/GetTeams", nil) + teamsRequest.Header.Set("Authorization", "Bearer "+loginResponse.AccessToken) + teamsRecorder := httptest.NewRecorder() + host.mux.ServeHTTP(teamsRecorder, teamsRequest) + if teamsRecorder.Code != http.StatusOK { + t.Fatalf("teams status: got %d, want %d", teamsRecorder.Code, http.StatusOK) + } + teams := &aiserverv1.GetTeamsResponse{} + if err := proto.Unmarshal(teamsRecorder.Body.Bytes(), teams); err != nil { + t.Fatalf("decode teams response: %v", err) + } + if len(teams.GetTeams()) != 1 || !teams.GetTeams()[0].GetIsEnterprise() { + t.Fatalf("unexpected teams response: %v", teams.GetTeams()) + } +} + +func TestHostAllowsWildcardCORS(t *testing.T) { + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + host, err := NewHost(store) + if err != nil { + t.Fatalf("new host: %v", err) + } + + preflightRequest := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token?plan=free", nil) + preflightRequest.Header.Set("Origin", "vscode-file://vscode-app") + preflightRequest.Header.Set("Access-Control-Request-Method", http.MethodGet) + preflightRequest.Header.Set("Access-Control-Request-Headers", "x-cursor-client-type") + preflightRecorder := httptest.NewRecorder() + host.mux.ServeHTTP(preflightRecorder, preflightRequest) + if preflightRecorder.Code != http.StatusNoContent { + t.Fatalf("preflight status: got %d, want %d", preflightRecorder.Code, http.StatusNoContent) + } + if got := preflightRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("preflight allow origin: got %q", got) + } + if got := preflightRecorder.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("preflight allow credentials: got %q, want empty", got) + } + if got := preflightRecorder.Header().Get("Access-Control-Allow-Headers"); got != "x-cursor-client-type" { + t.Fatalf("preflight allow headers: got %q", got) + } + + loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=free", nil) + loginRequest.Header.Set("Origin", "vscode-file://vscode-app") + loginRequest.Header.Set("x-cursor-client-type", "ide") + loginRecorder := httptest.NewRecorder() + host.mux.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("dev login status: got %d, want %d", loginRecorder.Code, http.StatusOK) + } + if got := loginRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("dev login allow origin: got %q", got) + } +} + +func TestHostAllowsRemoteWebOriginWithWildcard(t *testing.T) { + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + host, err := NewHost(store) + if err != nil { + t.Fatalf("new host: %v", err) + } + + request := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token", nil) + request.Header.Set("Origin", "https://example.com") + request.Header.Set("Access-Control-Request-Method", http.MethodGet) + recorder := httptest.NewRecorder() + host.mux.ServeHTTP(recorder, request) + if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("remote origin allow origin: got %q, want wildcard", got) + } +} + +func TestHostServesDevLoginOverTrustedLocalhostTLS(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve backend port: %v", err) + } + listenAddr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("release backend port: %v", err) + } + + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + config := serverconfig.DefaultConfig() + config.BackendListenAddr = listenAddr + if _, err := store.Save(context.Background(), config); err != nil { + t.Fatalf("save backend config: %v", err) + } + certificateManager, err := certs.NewEmbeddedManager() + if err != nil { + t.Fatalf("new certificate manager: %v", err) + } + serverCertificate, err := certificateManager.CertificateForServerName("localhost") + if err != nil { + t.Fatalf("create localhost certificate: %v", err) + } + host, err := NewHost(store, WithTLSCertificate(serverCertificate)) + if err != nil { + t.Fatalf("new TLS host: %v", err) + } + if err := host.Start(); err != nil { + t.Fatalf("start TLS host: %v", err) + } + defer func() { + stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := host.Stop(stopContext); err != nil { + t.Errorf("stop TLS host: %v", err) + } + }() + + caCertificate, err := certificateManager.CATLSCertificate() + if err != nil { + t.Fatalf("load CA certificate: %v", err) + } + roots := x509.NewCertPool() + roots.AddCert(caCertificate.Leaf) + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + ServerName: "localhost", + }}} + response, err := client.Get(host.BaseURL() + "/auth/cursor_dev_session_token?plan=pro&trial=true") + if err != nil { + t.Fatalf("request dev login over TLS: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("dev login TLS status: got %d, want %d", response.StatusCode, http.StatusOK) + } +} diff --git a/internal/backend/host_fallback_test.go b/internal/backend/host_fallback_test.go new file mode 100644 index 0000000..4dabae4 --- /dev/null +++ b/internal/backend/host_fallback_test.go @@ -0,0 +1,127 @@ +package backend + +import ( + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "cursor/internal/backend/server" + serverconfig "cursor/internal/backend/server/config" +) + +func TestHostForwardsUnhandledRoutesToOriginalUpstream(t *testing.T) { + var requestCount atomic.Int32 + upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestCount.Add(1) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("read upstream request body: %v", err) + } + writer.Header().Set("X-Upstream-Path", request.URL.RequestURI()) + writer.WriteHeader(http.StatusMultiStatus) + _, _ = writer.Write(body) + })) + defer upstreamServer.Close() + + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + host, err := NewHost(store) + if err != nil { + t.Fatalf("new host: %v", err) + } + + testCases := []struct { + name string + method string + path string + }{ + {name: "managed skills", path: "/aiserver.v1.DashboardService/GetManagedSkills?source=skills"}, + {name: "effective plugins", path: "/aiserver.v1.DashboardService/GetEffectiveUserPlugins?source=plugins"}, + {name: "MCP registry", path: "/aiserver.v1.MCPRegistryService/GetKnownServers?source=mcp"}, + {name: "auth poll", path: "/auth/poll?uuid=local-login&verifier=test"}, + {name: "OAuth token", path: "/oauth/token"}, + {name: "auth email", path: "/aiserver.v1.AuthService/GetEmail"}, + {name: "dashboard me", path: "/aiserver.v1.DashboardService/GetMe"}, + {name: "full stripe profile", method: http.MethodGet, path: "/auth/full_stripe_profile"}, + {name: "stripe profile", method: http.MethodGet, path: "/auth/stripe_profile"}, + {name: "valid payment method", method: http.MethodGet, path: "/auth/has_valid_payment_method"}, + {name: "auth logout", path: "/auth/logout"}, + {name: "dashboard global commands", path: "/aiserver.v1.DashboardService/GetGlobalCommands"}, + {name: "dashboard CLI download", path: "/aiserver.v1.DashboardService/GetCliDownloadUrl"}, + {name: "dashboard privacy mode", path: "/aiserver.v1.DashboardService/GetUserPrivacyMode"}, + {name: "service catch-all", path: "/aiserver.v1.NetworkService/UnknownProcedure?source=network"}, + {name: "AI handler miss", path: "/aiserver.v1.AiService/UnknownProcedure?source=ai"}, + {name: "global miss", path: "/unknown/service/path?source=global"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + method := testCase.method + if method == "" { + method = http.MethodPost + } + body := "payload-" + testCase.name + request := httptest.NewRequest(method, "http://localhost:8000"+testCase.path, strings.NewReader(body)) + request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+testCase.path) + recorder := httptest.NewRecorder() + + host.mux.ServeHTTP(recorder, request) + + if got := recorder.Code; got != http.StatusMultiStatus { + t.Fatalf("status: got %d, want %d; body=%s", got, http.StatusMultiStatus, recorder.Body.String()) + } + if got := recorder.Header().Get("X-Upstream-Path"); got != testCase.path { + t.Fatalf("upstream path: got %q, want %q", got, testCase.path) + } + wantBody := body + if method == http.MethodGet { + wantBody = "" + } + if got := recorder.Body.String(); got != wantBody { + t.Fatalf("response body: got %q, want %q", got, wantBody) + } + }) + } + + requestsBeforeHealthCheck := requestCount.Load() + healthRequest := httptest.NewRequest(http.MethodGet, "http://localhost:8000"+healthPath, nil) + healthRecorder := httptest.NewRecorder() + host.mux.ServeHTTP(healthRecorder, healthRequest) + if got := healthRecorder.Code; got != http.StatusOK { + t.Fatalf("health status: got %d, want %d", got, http.StatusOK) + } + if got := requestCount.Load(); got != requestsBeforeHealthCheck { + t.Fatalf("local health route unexpectedly reached upstream: requests before=%d after=%d", requestsBeforeHealthCheck, got) + } +} + +func TestHostFallbackKeepsWildcardCORSWhenUpstreamReturnsCORSHeaders(t *testing.T) { + upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Access-Control-Allow-Origin", "vscode-file://vscode-app") + writer.Header().Set("Access-Control-Allow-Credentials", "true") + writer.WriteHeader(http.StatusOK) + })) + defer upstreamServer.Close() + + store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir()) + host, err := NewHost(store) + if err != nil { + t.Fatalf("new host: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "http://localhost:8000/auth/poll?uuid=test", nil) + request.Header.Set("Origin", "vscode-file://vscode-app") + request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+request.URL.RequestURI()) + recorder := httptest.NewRecorder() + + host.mux.ServeHTTP(recorder, request) + + if got := recorder.Header().Values("Access-Control-Allow-Origin"); len(got) != 1 || got[0] != "*" { + t.Fatalf("allow origin values: got %q, want [*]", got) + } + if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("allow credentials: got %q, want empty", got) + } +} diff --git a/internal/backend/server/config/types.go b/internal/backend/server/config/types.go index c0a1999..321f036 100644 --- a/internal/backend/server/config/types.go +++ b/internal/backend/server/config/types.go @@ -13,7 +13,7 @@ import ( ) const ( - DefaultBackendListenAddr = "127.0.0.1:18090" + DefaultBackendListenAddr = "127.0.0.1:8000" DefaultProxyListenAddr = "127.0.0.1:18080" DefaultFrontendBaseURL = "http://127.0.0.1" DefaultProviderStreamIdleTimeoutSeconds = 240 diff --git a/internal/backend/server/upstream/action.go b/internal/backend/server/upstream/action.go index 70614cf..e6f635a 100644 --- a/internal/backend/server/upstream/action.go +++ b/internal/backend/server/upstream/action.go @@ -14,12 +14,13 @@ import ( type CompatRouteConfig struct { Name string StatusCode int - JSONBody map[string]any MockProtoType string MockBuilder func(*RequestContext) (map[string]any, error) ConsoleLog bool } +const DefaultCursorUpstreamBaseURL = "https://api2.cursor.sh:443" + func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { return func(ctx *server.Context) error { reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) @@ -30,31 +31,27 @@ func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc } } -// AuthenticatedForwardAction forwards a Cursor control-plane request with the -// independent desktop account after the local-mode identity rewrite has run. -func AuthenticatedForwardAction(deps Dependencies, cfg CompatRouteConfig, authorizationProvider AuthorizationProvider) server.HandlerFunc { +// FallbackForwardAction preserves an MITM request's original upstream URL. A +// native request has no original host metadata, so it is resolved against the +// configured default upstream while retaining its path and query string. +func FallbackForwardAction(deps Dependencies, cfg CompatRouteConfig, defaultBaseURL string) server.HandlerFunc { + forward := ForwardAction(deps, cfg) return func(ctx *server.Context) error { - reqCtx, _, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err + if ctx == nil || ctx.Request == nil || ctx.Request.URL == nil { + return fmt.Errorf("fallback upstream request context is invalid") } - if reqCtx == nil || reqCtx.Request == nil { - return fmt.Errorf("Cursor 控制面请求上下文无效") + if ctx.UpstreamURL == nil { + baseURL, err := ParseAndValidateRawURL(defaultBaseURL) + if err != nil { + return fmt.Errorf("parse fallback upstream URL: %w", err) + } + targetURL := *ctx.Request.URL + targetURL.Scheme = baseURL.Scheme + targetURL.Host = baseURL.Host + targetURL.User = baseURL.User + ctx.UpstreamURL = &targetURL } - 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 + return forward(ctx) } } @@ -68,63 +65,13 @@ func FixedStatusAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerF } } -func MockJSONAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { +func MockDevSessionTokenAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { return func(ctx *server.Context) error { reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) if err != nil { return err } - return handleMockJSON(reqCtx, route) - } -} - -func MockOAuthAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { - return func(ctx *server.Context) error { - reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err - } - return handleMockOAuth(reqCtx, route) - } -} - -func MockAuthFullStripeProfileAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { - return func(ctx *server.Context) error { - reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err - } - return handleMockAuthFullStripeProfile(reqCtx, route) - } -} - -func MockAuthStripeProfileAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { - return func(ctx *server.Context) error { - reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err - } - return handleMockAuthStripeProfile(reqCtx, route) - } -} - -func MockAuthPollAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { - return func(ctx *server.Context) error { - reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err - } - return handleMockAuthPoll(reqCtx, route) - } -} - -func MockAuthEmailAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { - return func(ctx *server.Context) error { - reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) - if err != nil { - return err - } - return handleMockAuthEmail(reqCtx, route) + return handleMockDevSessionToken(reqCtx, route) } } @@ -169,7 +116,6 @@ func newCompatRouteObjects(ctx *server.Context, deps Dependencies, cfg CompatRou Name: cfg.Name, Pattern: ctx.Request.URL.Path, StatusCode: cfg.StatusCode, - JSONBody: cfg.JSONBody, MockProtoType: cfg.MockProtoType, MockPayloadBuilder: cfg.MockBuilder, ConsoleLog: cfg.ConsoleLog, @@ -221,10 +167,6 @@ func DashboardTeamsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return buildDashboardTeamsPayload(reqCtx) } -func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { - return buildDashboardManagedSkillsPayload(reqCtx) -} - // EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту // достаточно успешного "пусто": нет team-настроек, нет репозиториев, // нет маркетплейсов/плагинов/команд, телеметрия принята без обработки. @@ -237,14 +179,6 @@ func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return map[string]any{"success": true}, nil } -func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) { - return buildDashboardGetMePayload(reqCtx) -} - -func DashboardUserPrivacyModeMockBuilder(reqCtx *RequestContext) (map[string]any, error) { - return buildDashboardUserPrivacyModePayload(reqCtx) -} - func DashboardPlanInfoMockBuilder(reqCtx *RequestContext) (map[string]any, error) { return buildDashboardPlanInfoPayload(reqCtx) } diff --git a/internal/backend/server/upstream/auth.go b/internal/backend/server/upstream/auth.go new file mode 100644 index 0000000..4bb65e4 --- /dev/null +++ b/internal/backend/server/upstream/auth.go @@ -0,0 +1,193 @@ +package upstream + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + legacyruntime "cursor/internal/runtime" +) + +const ( + localDevDefaultPlan = "ultra" + localDevTokenLifetime = 10 * 365 * 24 * time.Hour + localDevSubscriptionActive = "active" +) + +var localDevPlans = map[string]struct{}{ + "free": {}, + "pro": {}, + "pro_plus": {}, + "ultra": {}, + "enterprise": {}, +} + +type localDevSessionClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Plan string `json:"cursor_local_plan"` + Trial bool `json:"cursor_local_trial"` + TokenType string `json:"type"` + Issuer string `json:"iss"` + Scope string `json:"scope"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` +} + +func handleMockDevSessionToken(reqCtx *RequestContext, route *Route) error { + _ = route + if reqCtx == nil || reqCtx.Request == nil || reqCtx.ResponseWriter == nil { + return fmt.Errorf("dev session request context is invalid") + } + + plan, trial, email, err := parseLocalDevSessionQuery(reqCtx.Request) + if err != nil { + writeJSONError(reqCtx.ResponseWriter, http.StatusBadRequest, err.Error()) + return nil + } + + token, claims, err := buildLocalDevSessionToken(plan, trial, email, time.Now()) + if err != nil { + return err + } + responseBody, err := marshalJSONBody(map[string]any{ + "accessToken": token, + "refreshToken": token, + "authId": claims.Subject, + }) + if err != nil { + return err + } + reqCtx.ResponseWriter.Header().Set("content-type", "application/json") + reqCtx.ResponseWriter.WriteHeader(http.StatusOK) + _, _ = reqCtx.ResponseWriter.Write(responseBody) + return nil +} + +func parseLocalDevSessionQuery(request *http.Request) (string, bool, string, error) { + plan := localDevDefaultPlan + email := legacyruntime.InjectAccountEmail + if request == nil || request.URL == nil { + return plan, false, email, nil + } + + query := request.URL.Query() + if requestedPlan := strings.TrimSpace(query.Get("plan")); requestedPlan != "" { + plan = requestedPlan + } + if _, ok := localDevPlans[plan]; !ok { + return "", false, "", fmt.Errorf("unsupported dev plan %q", plan) + } + + trial := false + if rawTrial := strings.TrimSpace(query.Get("trial")); rawTrial != "" { + parsed, err := strconv.ParseBool(rawTrial) + if err != nil { + return "", false, "", fmt.Errorf("invalid trial value %q", rawTrial) + } + trial = parsed + } + if trial && plan != "pro" && plan != "pro_plus" { + return "", false, "", fmt.Errorf("trial is only supported for pro and pro_plus") + } + + if requestedEmail := strings.TrimSpace(query.Get("email")); requestedEmail != "" { + email = requestedEmail + } + return plan, trial, email, nil +} + +func buildLocalDevSessionToken(plan string, trial bool, email string, now time.Time) (string, localDevSessionClaims, error) { + authID := "local-dev-" + strings.ReplaceAll(plan, "_", "-") + if trial { + authID += "-trial" + } + claims := localDevSessionClaims{ + Subject: authID, + Email: strings.TrimSpace(email), + Plan: plan, + Trial: trial, + TokenType: "session", + Issuer: "cursor-local-backend", + Scope: "openid profile email", + IssuedAt: now.Unix(), + ExpiresAt: now.Add(localDevTokenLifetime).Unix(), + } + headerJSON, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + if err != nil { + return "", localDevSessionClaims{}, err + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", localDevSessionClaims{}, err + } + encode := base64.RawURLEncoding.EncodeToString + token := encode(headerJSON) + "." + encode(claimsJSON) + ".local-dev" + return token, claims, nil +} + +func localDevClaimsFromRequest(reqCtx *RequestContext) (localDevSessionClaims, bool) { + if reqCtx == nil { + return localDevSessionClaims{}, false + } + return localDevClaimsFromAuthorization(reqCtx.Headers.Get("authorization")) +} + +func localDevClaimsFromAuthorization(authorization string) (localDevSessionClaims, bool) { + authorization = strings.TrimSpace(authorization) + if len(authorization) >= len("Bearer ") && strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") { + authorization = strings.TrimSpace(authorization[len("Bearer "):]) + } + parts := strings.Split(authorization, ".") + if len(parts) != 3 { + return localDevSessionClaims{}, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return localDevSessionClaims{}, false + } + claims := localDevSessionClaims{} + if err := json.Unmarshal(payload, &claims); err != nil { + return localDevSessionClaims{}, false + } + if claims.Issuer != "cursor-local-backend" { + return localDevSessionClaims{}, false + } + if _, ok := localDevPlans[claims.Plan]; !ok || strings.TrimSpace(claims.Subject) == "" { + return localDevSessionClaims{}, false + } + return claims, true +} + +func localDevPlanFromRequest(reqCtx *RequestContext) string { + if claims, ok := localDevClaimsFromRequest(reqCtx); ok { + return claims.Plan + } + return localDevDefaultPlan +} + +func localDevPlanDetails(plan string) (string, int) { + switch plan { + case "free": + return "Free Plan", 0 + case "pro": + return "Pro Plan", 2000 + case "pro_plus": + return "Pro+ Plan", 6000 + case "enterprise": + return "Enterprise Plan", 0 + default: + return "Ultra Plan", localUltraPlanIncludedCents + } +} + +func writeJSONError(writer http.ResponseWriter, statusCode int, message string) { + writer.Header().Set("content-type", "application/json") + writer.WriteHeader(statusCode) + payload, _ := json.Marshal(map[string]string{"error": message}) + _, _ = writer.Write(payload) +} diff --git a/internal/backend/server/upstream/auth_test.go b/internal/backend/server/upstream/auth_test.go new file mode 100644 index 0000000..42a14c8 --- /dev/null +++ b/internal/backend/server/upstream/auth_test.go @@ -0,0 +1,138 @@ +package upstream + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cursor/gen/aiserverv1" + "cursor/internal/backend/server" + + "google.golang.org/protobuf/proto" +) + +func TestMockDevSessionTokenActionSupportsCursorDevLoginModes(t *testing.T) { + testCases := []struct { + name string + query string + plan string + trial bool + }{ + {name: "default", query: "", plan: "ultra"}, + {name: "free", query: "?plan=free", plan: "free"}, + {name: "pro trial", query: "?plan=pro&trial=true", plan: "pro", trial: true}, + {name: "pro", query: "?plan=pro", plan: "pro"}, + {name: "pro plus trial", query: "?plan=pro_plus&trial=true", plan: "pro_plus", trial: true}, + {name: "pro plus", query: "?plan=pro_plus", plan: "pro_plus"}, + {name: "ultra", query: "?plan=ultra", plan: "ultra"}, + {name: "enterprise", query: "?plan=enterprise", plan: "enterprise"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token"+testCase.query, nil) + recorder := httptest.NewRecorder() + handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK}) + if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil { + t.Fatalf("dev login handler: %v", err) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + AuthID string `json:"authId"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.AccessToken == "" || response.RefreshToken != response.AccessToken { + t.Fatalf("unexpected tokens: access=%q refresh=%q", response.AccessToken, response.RefreshToken) + } + claims, ok := localDevClaimsFromAuthorization("Bearer " + response.AccessToken) + if !ok { + t.Fatal("response access token is not a local dev JWT") + } + if claims.Plan != testCase.plan || claims.Trial != testCase.trial { + t.Fatalf("claims: got plan=%q trial=%v, want plan=%q trial=%v", claims.Plan, claims.Trial, testCase.plan, testCase.trial) + } + if response.AuthID != claims.Subject || claims.ExpiresAt <= time.Now().Unix() { + t.Fatalf("unexpected identity claims: response=%+v claims=%+v", response, claims) + } + }) + } +} + +func TestMockDevSessionTokenActionUsesRequestedEmail(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=pro&email=dev%2Bcursor%40example.com", nil) + recorder := httptest.NewRecorder() + handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK}) + if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil { + t.Fatalf("dev login handler: %v", err) + } + + var response map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + claims, ok := localDevClaimsFromAuthorization(response["accessToken"]) + if !ok || claims.Email != "dev+cursor@example.com" { + t.Fatalf("unexpected email claims: %+v", claims) + } +} + +func TestMockDevSessionTokenActionRejectsUnsupportedOptions(t *testing.T) { + for _, query := range []string{"?plan=business", "?plan=ultra&trial=true", "?plan=pro&trial=maybe"} { + request := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token"+query, nil) + recorder := httptest.NewRecorder() + handler := MockDevSessionTokenAction(Dependencies{}, CompatRouteConfig{Name: "dev_login", StatusCode: http.StatusOK}) + if err := handler(&server.Context{Writer: recorder, Request: request}); err != nil { + t.Fatalf("dev login handler for %q: %v", query, err) + } + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status for %q: got %d, want %d", query, recorder.Code, http.StatusBadRequest) + } + } +} + +func TestEnterpriseDevSessionProvidesBillableTeam(t *testing.T) { + token, _, err := buildLocalDevSessionToken("enterprise", false, "enterprise@example.com", time.Now()) + if err != nil { + t.Fatalf("build token: %v", err) + } + reqCtx := authRequestContext(http.MethodPost, "/aiserver.v1.DashboardService/GetTeams", "", token) + payload, err := buildDashboardTeamsPayload(reqCtx) + if err != nil { + t.Fatalf("build teams: %v", err) + } + encoded, err := encodeMockProto("aiserver.v1.GetTeamsResponse", payload) + if err != nil { + t.Fatalf("encode teams: %v", err) + } + response := &aiserverv1.GetTeamsResponse{} + if err := proto.Unmarshal(encoded, response); err != nil { + t.Fatalf("decode teams: %v", err) + } + if len(response.Teams) != 1 || !response.Teams[0].GetHasBilling() || response.Teams[0].GetSeats() == 0 || !response.Teams[0].GetIsEnterprise() { + t.Fatalf("unexpected enterprise teams: %+v", response.Teams) + } +} + +func authRequestContext(method string, path string, body string, token string) *RequestContext { + request := httptest.NewRequest(method, "http://local"+path, strings.NewReader(body)) + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + return &RequestContext{ + ResponseWriter: httptest.NewRecorder(), + Request: request, + Method: method, + Headers: request.Header.Clone(), + RequestBody: []byte(body), + } +} diff --git a/internal/backend/server/upstream/client.go b/internal/backend/server/upstream/client.go index dfedcd8..bd36743 100644 --- a/internal/backend/server/upstream/client.go +++ b/internal/backend/server/upstream/client.go @@ -2,23 +2,18 @@ package upstream import ( "bytes" - "crypto/sha256" - "encoding/base64" "encoding/json" "fmt" "io" - "math/big" "net/http" "net/url" "strconv" "strings" - "time" "cursor/gen/agentv1" "cursor/gen/aiserverv1" "cursor/internal/logger" "cursor/internal/netproxy" - legacyruntime "cursor/internal/runtime" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -87,14 +82,6 @@ func buildUpstreamRequest(reqCtx *RequestContext, body []byte, options ForwardOp } upstreamRequest.Host = reqCtx.TargetURL.Host - if shouldRewriteHost(reqCtx.TargetURL.Hostname()) { - auth := formatBearerAuthorization(legacyruntime.LocalRelayToken) - if auth == "" { - return nil, nil, legacyruntime.ErrInvalidSystemSetting - } - upstreamRequest.Header.Set("Authorization", auth) - upstreamRequest.Header.Set("x-cursor-checksum", BuildCursorChecksum(auth)) - } if options.PatchHeaders != nil { options.PatchHeaders(upstreamRequest.Header) } @@ -167,61 +154,21 @@ func copyRequestHeadersForUpstream(target http.Header, source http.Header) { } func copyResponseHeadersToClient(target http.Header, source http.Header) { + localWildcardCORS := target.Get("Access-Control-Allow-Origin") == "*" for key, values := range source { lowerKey := strings.ToLower(key) if _, exists := hopByHopHeaders[lowerKey]; exists { continue } + if localWildcardCORS && (lowerKey == "access-control-allow-origin" || lowerKey == "access-control-allow-credentials") { + continue + } for _, value := range values { target.Add(key, value) } } } -func shouldRewriteHost(host string) bool { - normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") - if normalized == "" { - return false - } - return normalized == "cursor.sh" || strings.HasSuffix(normalized, ".cursor.sh") -} - -func BuildCursorChecksum(authorization string) string { - const ( - checksumTimestampDivisor = 1_000_000 - checksumInitialSeed = 165 - ) - timestamp := time.Now().UnixMilli() / checksumTimestampDivisor - timestampBytes := make([]byte, 6) - timestampBigInt := big.NewInt(timestamp) - for index := 0; index < len(timestampBytes); index++ { - shift := uint((len(timestampBytes) - 1 - index) * 8) - timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff) - } - seed := checksumInitialSeed - for index := 0; index < len(timestampBytes); index++ { - current := int(timestampBytes[index]^byte(seed)) + (index % 256) - current &= 0xff - timestampBytes[index] = byte(current) - seed = current - } - prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=") - hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization))) - hash := fmt.Sprintf("%x", hashBytes) - return prefix + hash[:32] -} - -func formatBearerAuthorization(raw string) string { - value := strings.TrimSpace(raw) - if value == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(value), "bearer ") { - return value - } - return "Bearer " + value -} - func shouldRequestCarryBody(method string) bool { switch strings.ToUpper(strings.TrimSpace(method)) { case http.MethodGet, http.MethodHead, http.MethodDelete: @@ -238,17 +185,6 @@ func marshalJSONBody(payload map[string]any) ([]byte, error) { return json.Marshal(payload) } -func handleMockJSON(reqCtx *RequestContext, route *Route) error { - responseBody, err := marshalJSONBody(route.JSONBody) - if err != nil { - return err - } - reqCtx.ResponseWriter.Header().Set("content-type", "application/json") - reqCtx.ResponseWriter.WriteHeader(route.StatusCode) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - func handleMockProto(reqCtx *RequestContext, route *Route) error { payload := map[string]any{} if route.MockPayloadBuilder != nil { @@ -270,91 +206,6 @@ func handleMockProto(reqCtx *RequestContext, route *Route) error { return nil } -func handleMockOAuth(reqCtx *RequestContext, route *Route) error { - payload := struct { - RefreshToken string `json:"refresh_token"` - }{} - _ = json.Unmarshal(reqCtx.RequestBody, &payload) - responseBody, err := marshalJSONBody(map[string]any{ - "access_token": payload.RefreshToken, - "id_token": payload.RefreshToken, - "shouldLogout": false, - }) - if err != nil { - return err - } - reqCtx.ResponseWriter.Header().Set("content-type", "application/json") - reqCtx.ResponseWriter.WriteHeader(http.StatusOK) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - -func handleMockAuthFullStripeProfile(reqCtx *RequestContext, route *Route) error { - _ = route - responseBody, err := marshalJSONBody(map[string]any{ - "membershipType": localUltraMembershipType, - "subscriptionStatus": localUltraSubscriptionStatus, - "lastPaymentFailed": false, - "pendingCancellationDate": "", - "daysRemainingOnTrial": 0, - "paymentId": localUltraPaymentID, - }) - if err != nil { - return err - } - reqCtx.ResponseWriter.Header().Set("content-type", "application/json") - reqCtx.ResponseWriter.WriteHeader(http.StatusOK) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - -func handleMockAuthStripeProfile(reqCtx *RequestContext, route *Route) error { - _ = route - responseBody, err := json.Marshal(localUltraPaymentID) - if err != nil { - return err - } - reqCtx.ResponseWriter.Header().Set("content-type", "application/json") - reqCtx.ResponseWriter.WriteHeader(http.StatusOK) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - -func handleMockAuthPoll(reqCtx *RequestContext, route *Route) error { - _ = route - responseBody, err := marshalJSONBody(map[string]any{ - "accessToken": legacyruntime.InjectAuthToken, - "refreshToken": legacyruntime.InjectAuthToken, - "authId": "local_auth", - }) - if err != nil { - return err - } - reqCtx.ResponseWriter.Header().Set("content-type", "application/json") - reqCtx.ResponseWriter.WriteHeader(http.StatusOK) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - -func handleMockAuthEmail(reqCtx *RequestContext, route *Route) error { - _ = route - responseBody := encodeAuthGetEmailResponse(legacyruntime.InjectAccountEmail) - reqCtx.ResponseWriter.Header().Set("content-type", "application/proto") - reqCtx.ResponseWriter.Header().Set("content-length", strconv.Itoa(len(responseBody))) - reqCtx.ResponseWriter.WriteHeader(http.StatusOK) - _, _ = reqCtx.ResponseWriter.Write(responseBody) - return nil -} - -func encodeAuthGetEmailResponse(email string) []byte { - output := make([]byte, 0, len(email)+8) - output = append(output, 0x0a) - output = appendProtoVarint(output, uint64(len(email))) - output = append(output, []byte(email)...) - output = append(output, 0x10, 0x03) // GetEmailResponse.SignUpType.SIGN_UP_TYPE_GOOGLE - return output -} - func appendProtoVarint(output []byte, value uint64) []byte { for value >= 0x80 { output = append(output, byte(value)|0x80) @@ -431,8 +282,6 @@ func newProtoMessage(typeName string) (proto.Message, error) { return &aiserverv1.GetTeamAdminSettingsResponse{}, nil case "aiserver.v1.GetTeamReposResponse": return &aiserverv1.GetTeamReposResponse{}, nil - case "aiserver.v1.ListMarketplacesResponse": - return &aiserverv1.ListMarketplacesResponse{}, nil case "aiserver.v1.GetUsableModelsResponse": return &agentv1.GetUsableModelsResponse{}, nil case "aiserver.v1.GetDefaultModelForCliResponse": @@ -441,10 +290,6 @@ func newProtoMessage(typeName string) (proto.Message, error) { return &aiserverv1.GetDefaultModelResponse{}, nil case "aiserver.v1.GetGlobalCommandsResponse": return &aiserverv1.GetGlobalCommandsResponse{}, nil - case "aiserver.v1.GetEffectiveUserPluginsResponse": - return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil - case "aiserver.v1.RegisterMarketplaceAndPluginsResponse": - return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil case "aiserver.v1.GetCliDownloadUrlResponse": return &aiserverv1.GetCliDownloadUrlResponse{}, nil case "aiserver.v1.SubmitLogsResponse": diff --git a/internal/backend/server/upstream/fallback_action_test.go b/internal/backend/server/upstream/fallback_action_test.go new file mode 100644 index 0000000..9695b66 --- /dev/null +++ b/internal/backend/server/upstream/fallback_action_test.go @@ -0,0 +1,147 @@ +package upstream + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "cursor/internal/backend/server" +) + +type fallbackHTTPClientFunc func(*http.Request) (*http.Response, error) + +func (fn fallbackHTTPClientFunc) Do(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestFallbackForwardActionUsesOriginalMITMUpstreamURL(t *testing.T) { + originalURL := "https://api3.cursor.sh/aiserver.v1.UnknownService/Call?mode=exact" + parsedURL, err := url.Parse(originalURL) + if err != nil { + t.Fatalf("parse original URL: %v", err) + } + + client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) { + if got := request.URL.String(); got != originalURL { + t.Fatalf("upstream URL: got %q, want %q", got, originalURL) + } + if got := request.Method; got != http.MethodPost { + t.Fatalf("method: got %q, want POST", got) + } + body, readErr := io.ReadAll(request.Body) + if readErr != nil { + t.Fatalf("read request body: %v", readErr) + } + if got := string(body); got != "request-body" { + t.Fatalf("body: got %q", got) + } + if got := request.Header.Get("X-Test-Header"); got != "preserved" { + t.Fatalf("custom header: got %q", got) + } + if got := request.Header.Get(server.HeaderServerUpstreamURL); got != "" { + t.Fatalf("internal upstream header leaked: %q", got) + } + return &http.Response{ + StatusCode: http.StatusAccepted, + Status: "202 Accepted", + Header: http.Header{"X-Upstream-Response": []string{"preserved"}}, + Body: io.NopCloser(strings.NewReader("upstream-body")), + }, nil + }) + + request := httptest.NewRequest(http.MethodPost, "http://localhost:8000/ignored", strings.NewReader("request-body")) + request.Header.Set("X-Test-Header", "preserved") + request.Header.Set(server.HeaderServerUpstreamURL, originalURL) + recorder := httptest.NewRecorder() + ctx := &server.Context{Writer: recorder, Request: request, UpstreamURL: parsedURL} + action := FallbackForwardAction(Dependencies{HTTPClient: client}, CompatRouteConfig{Name: "fallback"}, DefaultCursorUpstreamBaseURL) + + if err := action(ctx); err != nil { + t.Fatalf("forward fallback request: %v", err) + } + if got := recorder.Code; got != http.StatusAccepted { + t.Fatalf("response status: got %d, want %d", got, http.StatusAccepted) + } + if got := recorder.Header().Get("X-Upstream-Response"); got != "preserved" { + t.Fatalf("response header: got %q", got) + } + if got := recorder.Body.String(); got != "upstream-body" { + t.Fatalf("response body: got %q", got) + } +} + +func TestFallbackForwardActionUsesDefaultUpstreamForNativeRequest(t *testing.T) { + const defaultBaseURL = "https://fallback.example:8443" + wantURL := defaultBaseURL + "/aiserver.v1.UnknownService/Call?mode=native" + client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) { + if got := request.URL.String(); got != wantURL { + t.Fatalf("upstream URL: got %q, want %q", got, wantURL) + } + return &http.Response{ + StatusCode: http.StatusNoContent, + Status: "204 No Content", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + + request := httptest.NewRequest(http.MethodGet, "http://localhost:8000/aiserver.v1.UnknownService/Call?mode=native", nil) + recorder := httptest.NewRecorder() + ctx := &server.Context{Writer: recorder, Request: request} + action := FallbackForwardAction(Dependencies{HTTPClient: client}, CompatRouteConfig{Name: "fallback"}, defaultBaseURL) + + if err := action(ctx); err != nil { + t.Fatalf("forward fallback request: %v", err) + } + if got := recorder.Code; got != http.StatusNoContent { + t.Fatalf("response status: got %d, want %d", got, http.StatusNoContent) + } +} + +func TestFallbackForwardActionPreservesAuthorization(t *testing.T) { + const ( + originalURL = "https://api2.cursor.sh/aiserver.v1.AuthService/GetEmail" + officialAuthorization = "Bearer official-access-token" + officialChecksum = "official-checksum" + ) + parsedURL, err := url.Parse(originalURL) + if err != nil { + t.Fatalf("parse original URL: %v", err) + } + + client := fallbackHTTPClientFunc(func(request *http.Request) (*http.Response, error) { + if got := request.Header.Get("Authorization"); got != officialAuthorization { + t.Fatalf("authorization: got %q, want %q", got, officialAuthorization) + } + if got := request.Header.Get("x-cursor-checksum"); got != officialChecksum { + t.Fatalf("checksum: got %q, want %q", got, officialChecksum) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("upstream-account")), + }, nil + }) + + request := httptest.NewRequest(http.MethodPost, "http://localhost:8000/aiserver.v1.AuthService/GetEmail", nil) + request.Header.Set("Authorization", officialAuthorization) + request.Header.Set("x-cursor-checksum", officialChecksum) + recorder := httptest.NewRecorder() + ctx := &server.Context{Writer: recorder, Request: request, UpstreamURL: parsedURL} + action := FallbackForwardAction( + Dependencies{HTTPClient: client}, + CompatRouteConfig{Name: "fallback"}, + DefaultCursorUpstreamBaseURL, + ) + + if err := action(ctx); err != nil { + t.Fatalf("forward authenticated fallback request: %v", err) + } + if got := recorder.Body.String(); got != "upstream-account" { + t.Fatalf("response body: got %q, want upstream-account", got) + } +} diff --git a/internal/backend/server/upstream/mocks.go b/internal/backend/server/upstream/mocks.go index f20b14b..d138834 100644 --- a/internal/backend/server/upstream/mocks.go +++ b/internal/backend/server/upstream/mocks.go @@ -24,9 +24,7 @@ const ( // файловых инструментов падают с "[unimplemented] HTTP 404". localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579" - localUltraMembershipType = "ultra" localUltraPaymentID = "local_ultra" - localUltraSubscriptionStatus = "active" localUltraPlanIncludedCents = 20000 localUltraDashboardUserID = 1 localUltraBillingCycleDuration = 30 * 24 * time.Hour @@ -431,7 +429,8 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) { func buildServerConfigPayload(*RequestContext) (map[string]any, error) { return map[string]any{ - "configVersion": "local_cli_sandbox_defaults_disabled_v2", + "configVersion": "local_cli_sandbox_defaults_disabled_v2", + "isDevDoNotUseForSecretThingsBecauseCanBeSpoofedByUsers": true, "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED", "cliSandboxDefaultEnabled": true, "indexingConfig": map[string]any{ @@ -547,26 +546,29 @@ func buildFirstWindowStatsigDecisionPayload(*RequestContext) (map[string]any, er }, nil } -func buildDashboardCurrentPeriodUsagePayload(*RequestContext) (map[string]any, error) { +func buildDashboardCurrentPeriodUsagePayload(reqCtx *RequestContext) (map[string]any, error) { + plan := localDevPlanFromRequest(reqCtx) + planName, includedSpend := localDevPlanDetails(plan) billingCycleStart := time.Now().Add(-localUltraBillingCycleDuration).UnixMilli() billingCycleEnd := time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli() + displayMessage := planName + " active" return map[string]any{ - "autoModelSelectedDisplayMessage": "Ultra plan active", + "autoModelSelectedDisplayMessage": displayMessage, "billingCycleEnd": billingCycleEnd, "billingCycleStart": billingCycleStart, - "displayMessage": "Ultra plan active", + "displayMessage": displayMessage, "displayThreshold": 99999999, "enabled": true, - "namedModelSelectedDisplayMessage": "Ultra plan active", + "namedModelSelectedDisplayMessage": displayMessage, "planUsage": map[string]any{ "apiPercentUsed": 0, "apiSpend": 0, "autoPercentUsed": 0, "autoSpend": 0, - "bonusTooltip": "Ultra local account mock is active.", - "includedSpend": localUltraPlanIncludedCents, - "limit": localUltraPlanIncludedCents, - "remaining": localUltraPlanIncludedCents, + "bonusTooltip": "Local account mock is active.", + "includedSpend": includedSpend, + "limit": includedSpend, + "remaining": includedSpend, "remainingBonus": false, "totalPercentUsed": 0, "totalSpend": 0, @@ -577,62 +579,45 @@ func buildDashboardCurrentPeriodUsagePayload(*RequestContext) (map[string]any, e }, nil } -func buildDashboardTeamsPayload(*RequestContext) (map[string]any, error) { +func buildDashboardTeamsPayload(reqCtx *RequestContext) (map[string]any, error) { + if claims, ok := localDevClaimsFromRequest(reqCtx); ok && claims.Plan == "enterprise" { + return map[string]any{ + "teams": []map[string]any{{ + "name": "Local Enterprise", + "id": 1, + "seats": 1, + "hasBilling": true, + "subscriptionStatus": localDevSubscriptionActive, + "verified": true, + "isEnterprise": true, + "membershipType": "enterprise", + }}, + }, nil + } return map[string]any{ "teams": []map[string]any{}, }, nil } -func buildDashboardManagedSkillsPayload(*RequestContext) (map[string]any, error) { - return map[string]any{ - "skills": []map[string]any{}, - }, nil -} - -func buildDashboardGetMePayload(reqCtx *RequestContext) (map[string]any, error) { - authID := "" - if reqCtx != nil { - authID = authIDFromBearer(reqCtx.Headers.Get("authorization")) +func buildDashboardPlanInfoPayload(reqCtx *RequestContext) (map[string]any, error) { + plan := localDevPlanFromRequest(reqCtx) + planName, includedAmountCents := localDevPlanDetails(plan) + price := "$200/mo" + switch plan { + case "free": + price = "$0/mo" + case "pro": + price = "$20/mo" + case "pro_plus": + price = "$60/mo" + case "enterprise": + price = "Custom" } - if authID == "" { - authID = authIDFromJWT(legacyruntime.InjectAuthToken) - } - if authID == "" { - authID = localUltraPaymentID - } - - return map[string]any{ - "authId": authID, - "userId": localUltraDashboardUserID, - "email": legacyruntime.InjectAccountEmail, - "firstName": "Cursor", - "lastName": "Local", - "createdAt": time.Now().UTC().Format(time.RFC3339), - "isEnterpriseUser": false, - "teamName": "", - "emailDomainType": "personal", - "country": "US", - "profilePictureUrl": "", - }, nil -} - -func buildDashboardUserPrivacyModePayload(*RequestContext) (map[string]any, error) { - return map[string]any{ - "privacyMode": "PRIVACY_MODE_NO_STORAGE", - "hoursRemainingInGracePeriod": 0, - "isEnforcedByTeam": false, - "isNotMigratedToServerSourceOfTruth": false, - "partnerDataShare": false, - "hasAcknowledgedGracePeriodDisclaimer": true, - }, nil -} - -func buildDashboardPlanInfoPayload(*RequestContext) (map[string]any, error) { return map[string]any{ "planInfo": map[string]any{ - "planName": "Ultra Plan", - "includedAmountCents": localUltraPlanIncludedCents, - "price": "$200/mo", + "planName": planName, + "includedAmountCents": includedAmountCents, + "price": price, "billingCycleEnd": time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli(), }, }, nil diff --git a/internal/backend/server/upstream/mocks_test.go b/internal/backend/server/upstream/mocks_test.go index 921e09b..21bf000 100644 --- a/internal/backend/server/upstream/mocks_test.go +++ b/internal/backend/server/upstream/mocks_test.go @@ -6,6 +6,7 @@ import ( "testing" "cursor/gen/agentv1" + "cursor/gen/aiserverv1" legacyruntime "cursor/internal/runtime" "google.golang.org/protobuf/proto" @@ -54,6 +55,26 @@ func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) { } } +func TestBuildServerConfigEnablesDevUserBackendCommands(t *testing.T) { + payload, err := buildServerConfigPayload(nil) + if err != nil { + t.Fatalf("build server config: %v", err) + } + + encoded, err := encodeMockProto("aiserver.v1.GetServerConfigResponse", payload) + if err != nil { + t.Fatalf("encode server config: %v", err) + } + + response := &aiserverv1.GetServerConfigResponse{} + if err := proto.Unmarshal(encoded, response); err != nil { + t.Fatalf("decode server config: %v", err) + } + if !response.GetIsDevDoNotUseForSecretThingsBecauseCanBeSpoofedByUsers() { + t.Fatal("expected server config to enable dev-user backend commands") + } +} + func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) { payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id") if err != nil { diff --git a/internal/backend/server/upstream/types.go b/internal/backend/server/upstream/types.go index 458a9f1..e40706d 100644 --- a/internal/backend/server/upstream/types.go +++ b/internal/backend/server/upstream/types.go @@ -20,13 +20,6 @@ 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) } @@ -91,7 +84,6 @@ type Route struct { Matcher Matcher ConsoleLog bool StatusCode int - JSONBody map[string]any MockProtoType string MockPayloadBuilder func(*RequestContext) (map[string]any, error) Handler RouteHandler diff --git a/internal/bridge/proxy.go b/internal/bridge/proxy.go index 75414f8..e5fed2c 100644 --- a/internal/bridge/proxy.go +++ b/internal/bridge/proxy.go @@ -30,9 +30,6 @@ type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest // ModelAdapterModelsResult 定义模型列表查询结果。 type ModelAdapterModelsResult = client.ModelAdapterModelsResult -// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。 -type CursorAccountStatus = client.CursorAccountStatus - // LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。 type LicenseActionRequest = client.LicenseActionRequest @@ -100,21 +97,6 @@ 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) diff --git a/internal/client/cursor_account.go b/internal/client/cursor_account.go deleted file mode 100644 index c384445..0000000 --- a/internal/client/cursor_account.go +++ /dev/null @@ -1,35 +0,0 @@ -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() -} diff --git a/internal/client/lifecycle.go b/internal/client/lifecycle.go index 2becde4..91c090c 100644 --- a/internal/client/lifecycle.go +++ b/internal/client/lifecycle.go @@ -11,7 +11,6 @@ import ( "cursor/internal/logger" "cursor/internal/mitm" "cursor/internal/netproxy" - localruntime "cursor/internal/runtime" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -85,11 +84,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) { if err := s.ensureProxy(cfg); err != nil { return fail("ensure_proxy", err) } - - // 启动时注入账号信息 - if err := cursor.InjectCursorUserInfo(localruntime.InjectAccountEmail, localruntime.InjectAuthToken); err != nil { - logger.Errorf("injectCursorUserInfo failed: %v", err) - // 不阻断启动,仅记录日志 + if err := cursor.DisableCursorStatsigGates(); err != nil { + logger.Errorf("disableCursorStatsigGates failed: %v", err) } if s.proxy != nil && !s.proxy.IsRunning() { @@ -265,9 +261,6 @@ func (s *ProxyService) ShutdownForQuit() { finalErr = errors.Join(finalErr, err) } } - if s.cursorAccount != nil { - s.cursorAccount.Shutdown() - } if finalErr != nil { s.setLastError(finalErr) } diff --git a/internal/client/service.go b/internal/client/service.go index 125bca9..225d46d 100644 --- a/internal/client/service.go +++ b/internal/client/service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "path/filepath" "sync" "time" @@ -12,7 +11,6 @@ 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" @@ -37,8 +35,6 @@ type ProxyService struct { certManager *certs.Manager // backendHost 表示当前嵌入式 backend 服务。 backendHost *backend.Host - // cursorAccount 持有仅供插件、Skills 和 MCP 控制面使用的真实 Cursor 身份。 - cursorAccount *cursoraccount.Manager // mu 表示当前声明中的 mu。 mu sync.RWMutex @@ -88,12 +84,8 @@ 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, service.cursorAccount) + host, err := service.newBackendHost() if err != nil { logger.Errorf("init backend host failed: %v", err) } else { @@ -109,7 +101,7 @@ func (s *ProxyService) ensureBackendHost() error { if s.backendHost != nil { return nil } - host, err := backend.NewHost(s.store, s.cursorAccount) + host, err := s.newBackendHost() if err != nil { return err } @@ -117,6 +109,18 @@ func (s *ProxyService) ensureBackendHost() error { return nil } +func (s *ProxyService) newBackendHost() (*backend.Host, error) { + options := []backend.HostOption{} + if s != nil && s.certManager != nil { + certificate, err := s.certManager.CertificateForServerName("localhost") + if err != nil { + return nil, fmt.Errorf("create localhost backend certificate: %w", err) + } + options = append(options, backend.WithTLSCertificate(certificate)) + } + return backend.NewHost(s.store, options...) +} + func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error { if s == nil { return nil diff --git a/internal/cursor/state_db.go b/internal/cursor/state_db.go index 940c0f1..5de9697 100644 --- a/internal/cursor/state_db.go +++ b/internal/cursor/state_db.go @@ -60,6 +60,23 @@ func InjectCursorUserInfo(email, token string) error { return nil } +// DisableCursorStatsigGates preserves the local-mode feature gates without +// injecting or replacing Cursor account state. +func DisableCursorStatsigGates() error { + stateDBPath, err := resolveCursorStateDBPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(stateDBPath), 0o755); err != nil { + return fmt.Errorf("创建 Cursor 状态目录失败: %w", err) + } + if err := disableCursorStatsigGatesInDB(stateDBPath); err != nil { + return fmt.Errorf("同步 Cursor Statsig gates 失败 path=%s: %w", stateDBPath, err) + } + logger.Infof("disableCursorStatsigGates synced path=%s gates=%s", stateDBPath, strings.Join(cursorStateDisabledStatsigGates, ",")) + return nil +} + func buildCursorAuthStateValues(email, token string) map[string]string { email = strings.TrimSpace(email) token = strings.TrimSpace(token) @@ -131,6 +148,44 @@ func syncCursorAuthStateDB(path string, values map[string]string) error { return nil } +func disableCursorStatsigGatesInDB(path string) error { + db, err := sql.Open("sqlite", path) + if err != nil { + return err + } + defer db.Close() + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + ctx := context.Background() + if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", cursorStateSQLiteBusyTimeoutMS)); err != nil { + return err + } + if _, err := db.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil { + return err + } + + tx, err := db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + if err := disableCursorStatsigGates(ctx, tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + committed = true + return nil +} + func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error { var raw []byte err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw) diff --git a/internal/cursor/state_db_test.go b/internal/cursor/state_db_test.go index 040b917..8de6ffa 100644 --- a/internal/cursor/state_db_test.go +++ b/internal/cursor/state_db_test.go @@ -59,6 +59,55 @@ func TestSyncCursorAuthStateDBDisablesCachedTerminalOutputUIStreamingIdempotentl } } +func TestDisableCursorStatsigGatesInDBDoesNotInjectAuthState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.vscdb") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open temporary state db: %v", err) + } + if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil { + db.Close() + t.Fatalf("create ItemTable: %v", err) + } + bootstrap := map[string]any{ + "feature_gates": map[string]any{}, + "hash_used": "none", + } + raw, err := json.Marshal(bootstrap) + if err != nil { + db.Close() + t.Fatalf("encode bootstrap: %v", err) + } + if _, err := db.Exec("INSERT INTO ItemTable(key, value) VALUES(?, ?)", cursorStateStatsigBootstrapKey, raw); err != nil { + db.Close() + t.Fatalf("insert bootstrap: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close setup db: %v", err) + } + + if err := disableCursorStatsigGatesInDB(path); err != nil { + t.Fatalf("disable statsig gates: %v", err) + } + updated := readCursorStatsigBootstrapForTest(t, path) + for _, gate := range cursorStateDisabledStatsigGates { + assertCursorStatsigGateValueForTest(t, updated, gate, false) + } + + db, err = sql.Open("sqlite", path) + if err != nil { + t.Fatalf("reopen state db: %v", err) + } + defer db.Close() + var authKeyCount int + if err := db.QueryRow("SELECT COUNT(*) FROM ItemTable WHERE key LIKE 'cursorAuth/%'").Scan(&authKeyCount); err != nil { + t.Fatalf("count auth keys: %v", err) + } + if authKeyCount != 0 { + t.Fatalf("statsig sync injected %d auth keys", authKeyCount) + } +} + func readCursorStatsigBootstrapForTest(t *testing.T, path string) []byte { t.Helper() db, err := sql.Open("sqlite", path) diff --git a/internal/cursoraccount/manager.go b/internal/cursoraccount/manager.go deleted file mode 100644 index 2191ac2..0000000 --- a/internal/cursoraccount/manager.go +++ /dev/null @@ -1,589 +0,0 @@ -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") -} diff --git a/internal/mitm/service.go b/internal/mitm/service.go index b7fefda..901fc4e 100644 --- a/internal/mitm/service.go +++ b/internal/mitm/service.go @@ -184,6 +184,19 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage return nil, err } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if certManager != nil { + caCertificate, err := certManager.CATLSCertificate() + if err != nil { + return nil, fmt.Errorf("load proxy backend CA: %w", err) + } + roots := x509.NewCertPool() + if caCertificate.Leaf != nil { + roots.AddCert(caCertificate.Leaf) + } + tlsConfig.RootCAs = roots + } + s := &ProxyServer{ addr: addr, baseURL: normalizedBaseURL, @@ -198,6 +211,7 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 60 * time.Second, + TLSClientConfig: tlsConfig, }, }, }