Compare commits

..
Author SHA1 Message Date
leookun c274a9db4c refactor: remove CursorAccountCard component and related localization entries
- Deleted the CursorAccountCard.vue component, which handled user account login and status.
- Removed associated localization entries from catalog.json and various language files.
- Updated Home.vue to eliminate references to the removed component.
- Refactored clientApi.js to remove unused account-related API functions.
2026-08-08 21:19:57 +08:00
leookun 3cf8bdbc3c docs: make English README the default
Keep the Chinese documentation available through a dedicated language link.
2026-08-08 16:22:12 +08:00
leokunandGitHub 684953a80b Merge pull request #279 from leookun/fix/cli-model-name
refactor: update model details handling in CLI
2026-08-08 15:46:22 +08:00
35 changed files with 1365 additions and 1886 deletions
+95
View File
@@ -0,0 +1,95 @@
<div align="center">
# cursor-byok
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![cursor-byok 支持接入多种模型 API](./images/cn-brand.png)
![cursor-byok 主界面](./images/cn-home.png)
## 项目介绍
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
> [!IMPORTANT]
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
## 核心能力
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
3. 测试模型配置;测试通过后返回主界面启动服务。
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
## 模型管理
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
![cursor-byok 模型配置](./images/cn-model.png)
## 工作原理
```text
Cursor 客户端
│ Agent 请求与工具结果
cursor-byok 本地服务
│ OpenAI / Anthropic 兼容请求
你配置的模型 API
```
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。
## 为什么做这个项目
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。
## 路线图
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与支持
- [使用教程](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 交流群](https://t.me/cursor_byok)
- QQ 交流群:`1095916242``1094411438``1095918002``1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## 开发与贡献
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
## 许可证
本项目基于 [MIT License](./LICENSE) 开源。
-95
View File
@@ -1,95 +0,0 @@
<div align="center">
# cursor-byok
[User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![cursor-byok dashboard](./images/en-home.png)
## About
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT]
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## Features
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **Cross-platform:** Run on macOS, Windows, and Linux.
## Quick Start
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Open Cursor, select the configured model, and start using Agent.
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model.png)
## How It Works
```text
Cursor client
│ Agent requests and tool results
cursor-byok local service
│ OpenAI- / Anthropic-compatible requests
Your model API
```
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
## Why This Project
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## Roadmap
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
## Community and Support
- [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## License
This project is open source under the [MIT License](./LICENSE).
+45 -45
View File
@@ -2,7 +2,7 @@
# cursor-byok # cursor-byok
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README-EN.md) [User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README-CN.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest) [![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases) [![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
@@ -11,85 +11,85 @@
</div> </div>
![cursor-byok 支持接入多种模型 API](./images/cn-brand.png) ![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![cursor-byok 主界面](./images/cn-home.png) ![cursor-byok dashboard](./images/en-home.png)
## 项目介绍 ## About
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。 cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
你可以接入 OpenAIAnthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。 You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT] > [!IMPORTANT]
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。 > cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## 核心能力 ## Features
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。 - **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **多种接口协议**:支持 OpenAIAnthropic 兼容接口及自定义端点。 - **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。 - **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。 - **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent 工作流**:支持工具调用、SkillsMCP 和多轮会话。 - **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。 - **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **跨平台运行**:支持 macOSWindows Linux - **Cross-platform:** Run on macOS, Windows, and Linux.
## 快速开始 ## Quick Start
1. [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。 1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。 2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. 测试模型配置;测试通过后返回主界面启动服务。 3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. 打开 Cursor,选择已配置的模型并开始使用 Agent 4. Open Cursor, select the configured model, and start using Agent.
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn) For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## 模型管理 ## Model Management
模型配置支持 OpenAI Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。 Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok 模型配置](./images/cn-model.png) ![cursor-byok model settings](./images/en-model.png)
## 工作原理 ## How It Works
```text ```text
Cursor 客户端 Cursor client
│ Agent 请求与工具结果 │ Agent requests and tool results
cursor-byok 本地服务 cursor-byok local service
│ OpenAI / Anthropic 兼容请求 │ OpenAI- / Anthropic-compatible requests
你配置的模型 API Your model API
``` ```
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。 cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
## 为什么做这个项目 ## Why This Project
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。 Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。 cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## 路线图 ## Roadmap
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。 The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32) See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
## 社区与支持 ## Community and Support
- [使用教程](https://docs.leokun.cn) - [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues) - [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 交流群](https://t.me/cursor_byok) - [Telegram community](https://t.me/cursor_byok)
- QQ 交流群:`1095916242``1094411438``1095918002``1094419321` - QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a> <a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## 开发与贡献 ## Development and Contributing
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md) Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## 许可证 ## License
本项目基于 [MIT License](./LICENSE) 开源。 This project is open source under the [MIT License](./LICENSE).
@@ -1,202 +0,0 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
getCursorAccountStatus,
startCursorAccountLogin,
} from "@/services/clientApi";
import { toUserError } from "@/state/appState";
import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const message = useMessage();
const cursorAccountStatus = ref({
state: "signed_out",
authId: "",
email: "",
error: "",
});
const cursorAccountBusy = ref(false);
let cursorAccountTimer = null;
function maskCursorAccountIdentifier(value) {
const identifier = String(value || "").trim();
if (!identifier) return "";
const atIndex = identifier.indexOf("@");
if (atIndex > 0 && atIndex < identifier.length - 1) {
const localPart = identifier.slice(0, atIndex);
const domain = identifier.slice(atIndex + 1);
const maskedLocalPart = localPart.length <= 2
? `${localPart[0]}***`
: `${localPart[0]}***${localPart.at(-1)}`;
return `${maskedLocalPart}@${domain}`;
}
if (identifier.length <= 8) return "****";
return `${identifier.slice(0, 4)}****${identifier.slice(-4)}`;
}
const cursorAccountSignedIn = computed(
() => cursorAccountStatus.value.state === "signed_in",
);
const cursorAccountWaiting = computed(
() => cursorAccountStatus.value.state === "waiting",
);
const cursorAccountDisplayIdentifier = computed(() => {
if (!cursorAccountSignedIn.value) return "";
return maskCursorAccountIdentifier(
cursorAccountStatus.value.email || cursorAccountStatus.value.authId,
);
});
const cursorAccountStateText = computed(() => {
if (cursorAccountSignedIn.value) return "已经登录";
if (cursorAccountWaiting.value) return "等待浏览器登录";
return "未连接";
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
async function handleOpenContributor() {
try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) {
showActionError("打开贡献者主页失败", toUserError(error));
}
}
async function refreshCursorAccountStatus() {
cursorAccountStatus.value = await getCursorAccountStatus();
}
async function handleCursorAccountLogin() {
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
}
}
async function handleCursorAccountDisconnect() {
const confirmed = await showModal({
title: "退出登录",
content: "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
confirmText: "退出登录",
cancelText: "取消",
showCancel: true,
});
if (!confirmed) return;
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
}
onMounted(async () => {
await refreshCursorAccountStatus().catch(() => {});
cursorAccountTimer = window.setInterval(() => {
if (cursorAccountWaiting.value) {
void refreshCursorAccountStatus().catch(() => {});
}
}, 1500);
});
onUnmounted(() => {
if (cursorAccountTimer) {
window.clearInterval(cursorAccountTimer);
cursorAccountTimer = null;
}
});
</script>
<template>
<Card>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h2 class="text-base font-medium text-white">Cursor 控制面账号</h2>
<span
class="rounded-full border border-[#3a3a3a] bg-[#202020] px-2 py-0.5 text-xs text-[#b8b8b8]"
>
{{ cursorAccountStateText }}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-xs text-[#737373]">
<span>@aike0210</span>
<Tooltip>
<div class="flex min-w-[220px] flex-col gap-2">
<div>感谢 @aike0210 Cursor 控制面账号功能的贡献</div>
<button
type="button"
class="flex items-center gap-2 text-left text-[#8ab4f8] transition-colors duration-150 hover:text-[#b6d0fb]"
@click="handleOpenContributor"
>
<span class="icon-[mdi--github] text-[14px]"></span>
<span>github.com/aike0210</span>
<span class="icon-[mdi--open-in-new] text-[12px]"></span>
</button>
</div>
</Tooltip>
</div>
</div>
<div class="flex items-end justify-between gap-4">
<div class="min-w-0">
<div
v-if="cursorAccountDisplayIdentifier"
class="truncate text-sm text-[#d0d0d0]"
>
{{ cursorAccountDisplayIdentifier }}
</div>
<div class="mt-1 text-sm text-[#a3a3a3]">
独立用于插件Skills MCP不会改变 Cursor 客户端当前账号
</div>
<div v-if="cursorAccountWaiting" class="mt-1 text-sm text-[#d6a84b]">
请在浏览器完成登录完成后返回 Cursor 重新打开插件市场
</div>
<div
v-if="cursorAccountStatus.error"
class="mt-1 break-all text-sm text-[#e06c75]"
>
{{ cursorAccountStatus.error }}
</div>
</div>
<Button
v-if="cursorAccountSignedIn"
class="shrink-0"
:disabled="cursorAccountBusy"
@click="handleCursorAccountDisconnect"
>
退出登录
</Button>
<Button
v-else
class="shrink-0"
variant="primary"
:disabled="cursorAccountBusy || cursorAccountWaiting"
@click="handleCursorAccountLogin"
>
{{ cursorAccountWaiting ? "等待登录..." : "登录 Cursor" }}
</Button>
</div>
</div>
</Card>
</template>
+12 -205
View File
@@ -250,18 +250,6 @@
} }
] ]
}, },
"1c631615c1d85c9e": {
"source": "登录 Cursor",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 197,
"column": 14
}
]
},
"1e238093b79b3165": { "1e238093b79b3165": {
"source": "留空时默认 65536", "source": "留空时默认 65536",
"kind": "text", "kind": "text",
@@ -313,12 +301,12 @@
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 118, "line": 117,
"column": 21 "column": 21
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 126, "line": 125,
"column": 21 "column": 21
} }
] ]
@@ -376,11 +364,6 @@
"kind": "text", "kind": "text",
"placeholders": 0, "placeholders": 0,
"refs": [ "refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 98,
"column": 17
},
{ {
"file": "src/components/ModelAdapterModal.vue", "file": "src/components/ModelAdapterModal.vue",
"line": 289, "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": { "3af7e5489e61ea51": {
"source": "刷新中", "source": "刷新中",
"kind": "text", "kind": "text",
@@ -655,18 +616,6 @@
} }
] ]
}, },
"3d52574ce1500561": {
"source": "未连接",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 61,
"column": 10
}
]
},
"3ea83f9f55062582": { "3ea83f9f55062582": {
"source": "发布时间:{0}", "source": "发布时间:{0}",
"kind": "template", "kind": "template",
@@ -893,18 +842,6 @@
} }
] ]
}, },
"58c6b0935a7216da": {
"source": "打开贡献者主页失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 73,
"column": 21
}
]
},
"593a972852ba0004": { "593a972852ba0004": {
"source": "Cursor助手|永久免费|自定义API", "source": "Cursor助手|永久免费|自定义API",
"kind": "text", "kind": "text",
@@ -1001,12 +938,12 @@
"refs": [ "refs": [
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 101, "line": 100,
"column": 21 "column": 21
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 111, "line": 110,
"column": 19 "column": 19
} }
] ]
@@ -1146,18 +1083,6 @@
} }
] ]
}, },
"688102a402ba015a": {
"source": "等待登录...",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 197,
"column": 14
}
]
},
"6a7b96f399e58138": { "6a7b96f399e58138": {
"source": "例如:sk-xxxxxx", "source": "例如:sk-xxxxxx",
"kind": "text", "kind": "text",
@@ -1192,16 +1117,6 @@
"kind": "text", "kind": "text",
"placeholders": 0, "placeholders": 0,
"refs": [ "refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 65,
"column": 34
},
{
"file": "src/components/CursorAccountCard.vue",
"line": 65,
"column": 52
},
{ {
"file": "src/state/appState.js", "file": "src/state/appState.js",
"line": 23, "line": 23,
@@ -1219,12 +1134,12 @@
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 84, "line": 83,
"column": 34 "column": 34
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 84, "line": 83,
"column": 52 "column": 52
}, },
{ {
@@ -1427,7 +1342,7 @@
"refs": [ "refs": [
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 91, "line": 90,
"column": 21 "column": 21
} }
] ]
@@ -1504,23 +1419,11 @@
"refs": [ "refs": [
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 108, "line": 107,
"column": 13 "column": 13
} }
] ]
}, },
"83be9cac28873059": {
"source": "Cursor 控制面账号",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 135,
"column": 56
}
]
},
"83fcfb4c1f2c1641": { "83fcfb4c1f2c1641": {
"source": "获取模型", "source": "获取模型",
"kind": "text", "kind": "text",
@@ -1620,7 +1523,7 @@
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 187, "line": 184,
"column": 68 "column": 68
} }
] ]
@@ -2088,7 +1991,7 @@
"refs": [ "refs": [
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 183, "line": 180,
"column": 47 "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": { "c5af02060847d167": {
"source": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "source": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"kind": "text", "kind": "text",
@@ -2396,23 +2287,11 @@
"refs": [ "refs": [
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 186, "line": 183,
"column": 63 "column": 63
} }
] ]
}, },
"c8a52b66651d294c": {
"source": "退出登录失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 107,
"column": 21
}
]
},
"c8c14507b2d37395": { "c8c14507b2d37395": {
"source": "推理强度", "source": "推理强度",
"kind": "text", "kind": "text",
@@ -2507,18 +2386,6 @@
} }
] ]
}, },
"cfa6c803eb3fc713": {
"source": "等待浏览器登录",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 60,
"column": 42
}
]
},
"d0325067fed88e5a": { "d0325067fed88e5a": {
"source": "缓存命中率 {0}", "source": "缓存命中率 {0}",
"kind": "template", "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": { "d7889896c5b7732a": {
"source": "Anthropic 额外参数 JSON", "source": "Anthropic 额外参数 JSON",
"kind": "text", "kind": "text",
@@ -2789,47 +2644,11 @@
}, },
{ {
"file": "src/views/Home.vue", "file": "src/views/Home.vue",
"line": 182, "line": 179,
"column": 56 "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": { "e552c2accdbf5178": {
"source": "新增模型", "source": "新增模型",
"kind": "text", "kind": "text",
@@ -2854,18 +2673,6 @@
} }
] ]
}, },
"e8a0a6053998ebfa": {
"source": "已经登录",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/components/CursorAccountCard.vue",
"line": 59,
"column": 43
}
]
},
"eaffd48cd2ea9f1a": { "eaffd48cd2ea9f1a": {
"source": "例如:https://api.anthropic.com", "source": "例如:https://api.anthropic.com",
"kind": "text", "kind": "text",
-14
View File
@@ -19,7 +19,6 @@
"1afed6a81a2512d2": "Select model", "1afed6a81a2512d2": "Select model",
"1baddde657dd2720": "Current outbound requests use system proxy", "1baddde657dd2720": "Current outbound requests use system proxy",
"1bc77f5ab979f4c1": "Add Model Settings", "1bc77f5ab979f4c1": "Add Model Settings",
"1c631615c1d85c9e": "Log in to Cursor",
"1e238093b79b3165": "Uses 65536 by default when left blank", "1e238093b79b3165": "Uses 65536 by default when left blank",
"21296ab18ad9af25": "Extra Params JSON", "21296ab18ad9af25": "Extra Params JSON",
"24343a2096988d42": "Failed to open", "24343a2096988d42": "Failed to open",
@@ -40,11 +39,9 @@
"37d23612f78a2e63": "Restart Now to Update", "37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme", "392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit", "393df9bb13ea4900": "Hit",
"3ab8cc15939f3b5c": "Log out",
"3af7e5489e61ea51": "Refreshing", "3af7e5489e61ea51": "Refreshing",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic", "3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language", "3d13868593ae4eeb": "Interface Language",
"3d52574ce1500561": "Not connected",
"3ea83f9f55062582": "Release date: {0}", "3ea83f9f55062582": "Release date: {0}",
"3edda85621fd03b2": "model adapters", "3edda85621fd03b2": "model adapters",
"3fd47edce45b3603": "Close", "3fd47edce45b3603": "Close",
@@ -63,7 +60,6 @@
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.", "5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"54e6745ff43c9c74": "Sorting failed", "54e6745ff43c9c74": "Sorting failed",
"56627c94a9decee6": "Max Output Tokens", "56627c94a9decee6": "Max Output Tokens",
"58c6b0935a7216da": "Failed to open contributor profile",
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API", "593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
"59a2195a01a8b35b": "{0} must be a valid JSON object", "59a2195a01a8b35b": "{0} must be a valid JSON object",
"5aa8f5590c940829": "Non-cache Input: {0}", "5aa8f5590c940829": "Non-cache Input: {0}",
@@ -82,7 +78,6 @@
"66af574b8948fe83": "{0} API key cannot be empty", "66af574b8948fe83": "{0} API key cannot be empty",
"6744b4c6a9aa0038": "Disabled", "6744b4c6a9aa0038": "Disabled",
"675109292da4eb36": "Not tested yet", "675109292da4eb36": "Not tested yet",
"688102a402ba015a": "Waiting for login...",
"6a7b96f399e58138": "e.g. sk-xxxxxx", "6a7b96f399e58138": "e.g. sk-xxxxxx",
"6aa8f49cc992dfd7": "Test", "6aa8f49cc992dfd7": "Test",
"6ae23d6d7cb18592": "Service error", "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.", "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", "8151e8704a7ca89e": "No matches",
"83913e71fcf7ff60": "Refresh successful", "83913e71fcf7ff60": "Refresh successful",
"83be9cac28873059": "Cursor Control Plane Account",
"83fcfb4c1f2c1641": "Fetch Models", "83fcfb4c1f2c1641": "Fetch Models",
"8672864e90417138": "Max", "8672864e90417138": "Max",
"86df7ec743047234": "Service running", "86df7ec743047234": "Service running",
@@ -168,11 +162,9 @@
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection", "bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
"bef280f9eb392495": "Conversation Turns", "bef280f9eb392495": "Conversation Turns",
"c228558cf257fc49": "Delete failed", "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.", "c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
"c6868592796ac2b2": "No {0} models have been configured yet.", "c6868592796ac2b2": "No {0} models have been configured yet.",
"c69f5bce63b9f14c": "Settings Folder", "c69f5bce63b9f14c": "Settings Folder",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort", "c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model", "c98e118e0a43f078": "Model",
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)", "c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
@@ -180,7 +172,6 @@
"ca1d1059408b3837": "Invalid turns: {0}", "ca1d1059408b3837": "Invalid turns: {0}",
"cc5049729a2c10f1": "Test failed. Check the raw details.", "cc5049729a2c10f1": "Test failed. Check the raw details.",
"cd7ca5fb221e1c53": "{0} cannot be empty", "cd7ca5fb221e1c53": "{0} cannot be empty",
"cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}", "d0325067fed88e5a": "Cache hit rate {0}",
"d20ab96566d33f25": "{0} display name cannot be empty", "d20ab96566d33f25": "{0} display name cannot be empty",
"d2243e1d44b2a94e": "Edit Model Settings", "d2243e1d44b2a94e": "Edit Model Settings",
@@ -188,7 +179,6 @@
"d373809ab86ba93b": "Copy", "d373809ab86ba93b": "Copy",
"d3b1da3088ddd334": "Model test failed", "d3b1da3088ddd334": "Model test failed",
"d53d32f1a1211371": "Custom Headers JSON", "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", "d7889896c5b7732a": "Anthropic Extra Params JSON",
"d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)", "d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)",
"d95e5cb6bdcee553": "Include Cache Creation", "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.", "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}", "e14c41ef2b7253c9": "Total request tokens: {0}",
"e406825e0a72d2c2": "Local Settings", "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", "e552c2accdbf5178": "Add Model",
"e6faccfddce722e8": "Cache read tokens: {0}", "e6faccfddce722e8": "Cache read tokens: {0}",
"e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com", "eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
"eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.", "eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.",
"ec3b17a75db49e24": "{0} t/s | First token {1}", "ec3b17a75db49e24": "{0} t/s | First token {1}",
-14
View File
@@ -19,7 +19,6 @@
"1afed6a81a2512d2": "モデルを選択", "1afed6a81a2512d2": "モデルを選択",
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています", "1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
"1bc77f5ab979f4c1": "モデル設定を追加", "1bc77f5ab979f4c1": "モデル設定を追加",
"1c631615c1d85c9e": "Cursor にログイン",
"1e238093b79b3165": "空欄で 65536", "1e238093b79b3165": "空欄で 65536",
"21296ab18ad9af25": "追加パラメータ JSON", "21296ab18ad9af25": "追加パラメータ JSON",
"24343a2096988d42": "開けませんでした", "24343a2096988d42": "開けませんでした",
@@ -40,11 +39,9 @@
"37d23612f78a2e63": "今すぐ再起動して更新", "37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高", "392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット", "393df9bb13ea4900": "ヒット",
"3ab8cc15939f3b5c": "ログアウト",
"3af7e5489e61ea51": "更新中", "3af7e5489e61ea51": "更新中",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします", "3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語", "3d13868593ae4eeb": "表示言語",
"3d52574ce1500561": "未接続",
"3ea83f9f55062582": "公開日時: {0}", "3ea83f9f55062582": "公開日時: {0}",
"3edda85621fd03b2": "件のモデルアダプター", "3edda85621fd03b2": "件のモデルアダプター",
"3fd47edce45b3603": "閉じる", "3fd47edce45b3603": "閉じる",
@@ -63,7 +60,6 @@
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。", "5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました", "54e6745ff43c9c74": "並べ替えに失敗しました",
"56627c94a9decee6": "最大出力 Token", "56627c94a9decee6": "最大出力 Token",
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API", "593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります", "59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
"5aa8f5590c940829": "非キャッシュ入力:{0}", "5aa8f5590c940829": "非キャッシュ入力:{0}",
@@ -82,7 +78,6 @@
"66af574b8948fe83": "{0} の API キーは必須です", "66af574b8948fe83": "{0} の API キーは必須です",
"6744b4c6a9aa0038": "無効化", "6744b4c6a9aa0038": "無効化",
"675109292da4eb36": "まだテストしていません", "675109292da4eb36": "まだテストしていません",
"688102a402ba015a": "ログインを待っています...",
"6a7b96f399e58138": "例: sk-xxxxxx", "6a7b96f399e58138": "例: sk-xxxxxx",
"6aa8f49cc992dfd7": "テスト", "6aa8f49cc992dfd7": "テスト",
"6ae23d6d7cb18592": "サービスエラー", "6ae23d6d7cb18592": "サービスエラー",
@@ -106,7 +101,6 @@
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。", "8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"8151e8704a7ca89e": "一致する項目がありません", "8151e8704a7ca89e": "一致する項目がありません",
"83913e71fcf7ff60": "更新しました", "83913e71fcf7ff60": "更新しました",
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
"83fcfb4c1f2c1641": "モデルを取得", "83fcfb4c1f2c1641": "モデルを取得",
"8672864e90417138": "最大", "8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中", "86df7ec743047234": "サービス稼働中",
@@ -168,11 +162,9 @@
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます", "bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
"bef280f9eb392495": "会話ターン", "bef280f9eb392495": "会話ターン",
"c228558cf257fc49": "削除に失敗しました", "c228558cf257fc49": "削除に失敗しました",
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。", "c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。", "c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
"c69f5bce63b9f14c": "設定フォルダー", "c69f5bce63b9f14c": "設定フォルダー",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度", "c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル", "c98e118e0a43f078": "モデル",
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)", "c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
@@ -180,7 +172,6 @@
"ca1d1059408b3837": "異常ターン: {0}", "ca1d1059408b3837": "異常ターン: {0}",
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。", "cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
"cd7ca5fb221e1c53": "{0}は空にできません", "cd7ca5fb221e1c53": "{0}は空にできません",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}", "d0325067fed88e5a": "キャッシュヒット率 {0}",
"d20ab96566d33f25": "{0} の表示名は必須です", "d20ab96566d33f25": "{0} の表示名は必須です",
"d2243e1d44b2a94e": "モデル設定を編集", "d2243e1d44b2a94e": "モデル設定を編集",
@@ -188,7 +179,6 @@
"d373809ab86ba93b": "コピー", "d373809ab86ba93b": "コピー",
"d3b1da3088ddd334": "モデルテストに失敗しました", "d3b1da3088ddd334": "モデルテストに失敗しました",
"d53d32f1a1211371": "カスタムヘッダー JSON", "d53d32f1a1211371": "カスタムヘッダー JSON",
"d6ce4f0f88178144": "プラグイン、Skills、MCP 専用です。Cursor クライアントの現在のアカウントは変更しません",
"d7889896c5b7732a": "Anthropic 追加パラメータ JSON", "d7889896c5b7732a": "Anthropic 追加パラメータ JSON",
"d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)", "d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)",
"d95e5cb6bdcee553": "キャッシュ作成を含める", "d95e5cb6bdcee553": "キャッシュ作成を含める",
@@ -201,12 +191,8 @@
"e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。", "e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。",
"e14c41ef2b7253c9": "総リクエスト Token: {0}", "e14c41ef2b7253c9": "総リクエスト Token: {0}",
"e406825e0a72d2c2": "ローカル設定", "e406825e0a72d2c2": "ローカル設定",
"e4343921c928a856": "ログインに失敗しました",
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加", "e552c2accdbf5178": "モデルを追加",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}", "e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com", "eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
"eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。", "eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。",
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}", "ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
-14
View File
@@ -19,7 +19,6 @@
"1afed6a81a2512d2": "Выберите модель", "1afed6a81a2512d2": "Выберите модель",
"1baddde657dd2720": "Исходящие запросы используют системный прокси", "1baddde657dd2720": "Исходящие запросы используют системный прокси",
"1bc77f5ab979f4c1": "Добавить настройки модели", "1bc77f5ab979f4c1": "Добавить настройки модели",
"1c631615c1d85c9e": "Войти в Cursor",
"1e238093b79b3165": "Если оставить пустым, используется 65536", "1e238093b79b3165": "Если оставить пустым, используется 65536",
"21296ab18ad9af25": "Дополнительные параметры JSON", "21296ab18ad9af25": "Дополнительные параметры JSON",
"24343a2096988d42": "Не удалось открыть", "24343a2096988d42": "Не удалось открыть",
@@ -40,11 +39,9 @@
"37d23612f78a2e63": "Перезапустить и обновить", "37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая", "392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание", "393df9bb13ea4900": "Попадание",
"3ab8cc15939f3b5c": "Выйти",
"3af7e5489e61ea51": "Обновление", "3af7e5489e61ea51": "Обновление",
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic", "3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
"3d13868593ae4eeb": "Язык интерфейса", "3d13868593ae4eeb": "Язык интерфейса",
"3d52574ce1500561": "Не подключено",
"3ea83f9f55062582": "Дата выпуска: {0}", "3ea83f9f55062582": "Дата выпуска: {0}",
"3edda85621fd03b2": "адаптеров моделей", "3edda85621fd03b2": "адаптеров моделей",
"3fd47edce45b3603": "Закрыть", "3fd47edce45b3603": "Закрыть",
@@ -63,7 +60,6 @@
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.", "5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок", "54e6745ff43c9c74": "Не удалось изменить порядок",
"56627c94a9decee6": "Макс. выходных токенов", "56627c94a9decee6": "Макс. выходных токенов",
"58c6b0935a7216da": "Не удалось открыть профиль участника",
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API", "593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON", "59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
"5aa8f5590c940829": "Ввод без кеша: {0}", "5aa8f5590c940829": "Ввод без кеша: {0}",
@@ -82,7 +78,6 @@
"66af574b8948fe83": "Ключ API {0} не может быть пустым", "66af574b8948fe83": "Ключ API {0} не может быть пустым",
"6744b4c6a9aa0038": "Выключено", "6744b4c6a9aa0038": "Выключено",
"675109292da4eb36": "Еще не проверено", "675109292da4eb36": "Еще не проверено",
"688102a402ba015a": "Ожидание входа...",
"6a7b96f399e58138": "например, sk-xxxxxx", "6a7b96f399e58138": "например, sk-xxxxxx",
"6aa8f49cc992dfd7": "Проверить", "6aa8f49cc992dfd7": "Проверить",
"6ae23d6d7cb18592": "Ошибка сервиса", "6ae23d6d7cb18592": "Ошибка сервиса",
@@ -106,7 +101,6 @@
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.", "8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"8151e8704a7ca89e": "Совпадений нет", "8151e8704a7ca89e": "Совпадений нет",
"83913e71fcf7ff60": "Обновление выполнено", "83913e71fcf7ff60": "Обновление выполнено",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"83fcfb4c1f2c1641": "Получить модели", "83fcfb4c1f2c1641": "Получить модели",
"8672864e90417138": "Максимальная", "8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен", "86df7ec743047234": "Сервис запущен",
@@ -168,11 +162,9 @@
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение", "bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
"bef280f9eb392495": "Ходы диалога", "bef280f9eb392495": "Ходы диалога",
"c228558cf257fc49": "Не удалось удалить", "c228558cf257fc49": "Не удалось удалить",
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.", "c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c6868592796ac2b2": "Модели {0} пока не настроены.", "c6868592796ac2b2": "Модели {0} пока не настроены.",
"c69f5bce63b9f14c": "Папка настроек", "c69f5bce63b9f14c": "Папка настроек",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений", "c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель", "c98e118e0a43f078": "Модель",
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)", "c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
@@ -180,7 +172,6 @@
"ca1d1059408b3837": "Ошибочных ходов: {0}", "ca1d1059408b3837": "Ошибочных ходов: {0}",
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.", "cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
"cd7ca5fb221e1c53": "{0} не может быть пустым", "cd7ca5fb221e1c53": "{0} не может быть пустым",
"cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}", "d0325067fed88e5a": "Доля попаданий в кеш: {0}",
"d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым", "d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым",
"d2243e1d44b2a94e": "Изменить настройки модели", "d2243e1d44b2a94e": "Изменить настройки модели",
@@ -188,7 +179,6 @@
"d373809ab86ba93b": "Копировать", "d373809ab86ba93b": "Копировать",
"d3b1da3088ddd334": "Проверка модели не пройдена", "d3b1da3088ddd334": "Проверка модели не пройдена",
"d53d32f1a1211371": "Пользовательские заголовки JSON", "d53d32f1a1211371": "Пользовательские заголовки JSON",
"d6ce4f0f88178144": "Используется только для Plugins, Skills и MCP; текущий аккаунт клиента Cursor не изменяется",
"d7889896c5b7732a": "Дополнительные параметры Anthropic JSON", "d7889896c5b7732a": "Дополнительные параметры Anthropic JSON",
"d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)", "d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)",
"d95e5cb6bdcee553": "Учитывать создание кеша", "d95e5cb6bdcee553": "Учитывать создание кеша",
@@ -201,12 +191,8 @@
"e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.", "e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.",
"e14c41ef2b7253c9": "Всего токенов запроса: {0}", "e14c41ef2b7253c9": "Всего токенов запроса: {0}",
"e406825e0a72d2c2": "Локальные настройки", "e406825e0a72d2c2": "Локальные настройки",
"e4343921c928a856": "Не удалось войти",
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель", "e552c2accdbf5178": "Добавить модель",
"e6faccfddce722e8": "Токены чтения из кеша: {0}", "e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com", "eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
"eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.", "eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.",
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}", "ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
-14
View File
@@ -19,7 +19,6 @@
"1afed6a81a2512d2": "选择模型", "1afed6a81a2512d2": "选择模型",
"1baddde657dd2720": "当前出站请求使用系统代理", "1baddde657dd2720": "当前出站请求使用系统代理",
"1bc77f5ab979f4c1": "新增模型配置", "1bc77f5ab979f4c1": "新增模型配置",
"1c631615c1d85c9e": "登录 Cursor",
"1e238093b79b3165": "留空时默认 65536", "1e238093b79b3165": "留空时默认 65536",
"21296ab18ad9af25": "额外参数 JSON", "21296ab18ad9af25": "额外参数 JSON",
"24343a2096988d42": "打开失败", "24343a2096988d42": "打开失败",
@@ -40,11 +39,9 @@
"37d23612f78a2e63": "立即重启更新", "37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高", "392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中", "393df9bb13ea4900": "命中",
"3ab8cc15939f3b5c": "退出登录",
"3af7e5489e61ea51": "刷新中", "3af7e5489e61ea51": "刷新中",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic", "3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言", "3d13868593ae4eeb": "界面语言",
"3d52574ce1500561": "未连接",
"3ea83f9f55062582": "发布时间:{0}", "3ea83f9f55062582": "发布时间:{0}",
"3edda85621fd03b2": "个模型适配器", "3edda85621fd03b2": "个模型适配器",
"3fd47edce45b3603": "关闭", "3fd47edce45b3603": "关闭",
@@ -63,7 +60,6 @@
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。", "5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败", "54e6745ff43c9c74": "排序失败",
"56627c94a9decee6": "最大输出 Token", "56627c94a9decee6": "最大输出 Token",
"58c6b0935a7216da": "打开贡献者主页失败",
"593a972852ba0004": "Cursor助手|永久免费|自定义API", "593a972852ba0004": "Cursor助手|永久免费|自定义API",
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象", "59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
"5aa8f5590c940829": "非缓存输入:{0}", "5aa8f5590c940829": "非缓存输入:{0}",
@@ -82,7 +78,6 @@
"66af574b8948fe83": "{0} 的访问密钥不能为空", "66af574b8948fe83": "{0} 的访问密钥不能为空",
"6744b4c6a9aa0038": "已关闭", "6744b4c6a9aa0038": "已关闭",
"675109292da4eb36": "尚未测试", "675109292da4eb36": "尚未测试",
"688102a402ba015a": "等待登录...",
"6a7b96f399e58138": "例如:sk-xxxxxx", "6a7b96f399e58138": "例如:sk-xxxxxx",
"6aa8f49cc992dfd7": "测试", "6aa8f49cc992dfd7": "测试",
"6ae23d6d7cb18592": "服务错误", "6ae23d6d7cb18592": "服务错误",
@@ -106,7 +101,6 @@
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", "8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"8151e8704a7ca89e": "没有匹配项", "8151e8704a7ca89e": "没有匹配项",
"83913e71fcf7ff60": "刷新成功", "83913e71fcf7ff60": "刷新成功",
"83be9cac28873059": "Cursor 控制面账号",
"83fcfb4c1f2c1641": "获取模型", "83fcfb4c1f2c1641": "获取模型",
"8672864e90417138": "最高", "8672864e90417138": "最高",
"86df7ec743047234": "服务运行中", "86df7ec743047234": "服务运行中",
@@ -168,11 +162,9 @@
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理", "bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
"bef280f9eb392495": "对话轮次", "bef280f9eb392495": "对话轮次",
"c228558cf257fc49": "删除失败", "c228558cf257fc49": "删除失败",
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。", "c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
"c69f5bce63b9f14c": "设置文件夹", "c69f5bce63b9f14c": "设置文件夹",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度", "c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型", "c98e118e0a43f078": "模型",
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)", "c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
@@ -180,7 +172,6 @@
"ca1d1059408b3837": "异常轮次:{0}", "ca1d1059408b3837": "异常轮次:{0}",
"cc5049729a2c10f1": "测试失败,请查看原始信息", "cc5049729a2c10f1": "测试失败,请查看原始信息",
"cd7ca5fb221e1c53": "{0}不能为空", "cd7ca5fb221e1c53": "{0}不能为空",
"cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}", "d0325067fed88e5a": "缓存命中率 {0}",
"d20ab96566d33f25": "{0} 的显示名称不能为空", "d20ab96566d33f25": "{0} 的显示名称不能为空",
"d2243e1d44b2a94e": "编辑模型配置", "d2243e1d44b2a94e": "编辑模型配置",
@@ -188,7 +179,6 @@
"d373809ab86ba93b": "拷贝", "d373809ab86ba93b": "拷贝",
"d3b1da3088ddd334": "模型测试失败", "d3b1da3088ddd334": "模型测试失败",
"d53d32f1a1211371": "自定义请求头 JSON", "d53d32f1a1211371": "自定义请求头 JSON",
"d6ce4f0f88178144": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号",
"d7889896c5b7732a": "Anthropic 额外参数 JSON", "d7889896c5b7732a": "Anthropic 额外参数 JSON",
"d7da2aabd35772ec": "例如:200000(留空用默认值)", "d7da2aabd35772ec": "例如:200000(留空用默认值)",
"d95e5cb6bdcee553": "计入缓存创建", "d95e5cb6bdcee553": "计入缓存创建",
@@ -201,12 +191,8 @@
"e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。", "e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
"e14c41ef2b7253c9": "总请求:{0}", "e14c41ef2b7253c9": "总请求:{0}",
"e406825e0a72d2c2": "本地配置", "e406825e0a72d2c2": "本地配置",
"e4343921c928a856": "登录失败",
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型", "e552c2accdbf5178": "新增模型",
"e6faccfddce722e8": "缓存读取:{0}", "e6faccfddce722e8": "缓存读取:{0}",
"e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com", "eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"ec3b17a75db49e24": "{0} t/s | 首字 {1}", "ec3b17a75db49e24": "{0} t/s | 首字 {1}",
-15
View File
@@ -1,10 +1,7 @@
import { import {
DisconnectCursorAccount,
GetCursorAccountStatus,
GetState, GetState,
LoadUserConfig, LoadUserConfig,
SaveUserConfig, SaveUserConfig,
StartCursorAccountLogin,
StartProxy, StartProxy,
StopProxy, StopProxy,
} from "@bindings/cursor/internal/bridge/proxyservice.js"; } from "@bindings/cursor/internal/bridge/proxyservice.js";
@@ -63,18 +60,6 @@ export function saveUserConfig(payload) {
return withApiLogging("SaveUserConfig", payload, () => 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() { export function getProxyState() {
return withApiLogging("GetState", undefined, () => GetState()); return withApiLogging("GetState", undefined, () => GetState());
} }
-3
View File
@@ -2,7 +2,6 @@
import Button from "@/components/ui/Button.vue"; import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue"; import Card from "@/components/ui/Card.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue"; import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import CursorAccountCard from "@/components/CursorAccountCard.vue";
import { useMessage } from "@/composables/useMessage"; import { useMessage } from "@/composables/useMessage";
import { getAdRuntime } from "@/services/clientApi"; import { getAdRuntime } from "@/services/clientApi";
import { import {
@@ -174,8 +173,6 @@ onBeforeUnmount(() => {
</div> </div>
</Card> </Card>
<CursorAccountCard />
<Card class=""> <Card class="">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
+6 -3
View File
@@ -78,7 +78,7 @@ func Run(resources EmbeddedResources) error {
return err return err
} }
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr defaultBackendBaseURL := browserReachableLoopbackBaseURL(serverconfig.DefaultBackendListenAddr)
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager) proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
if err != nil { if err != nil {
return err return err
@@ -433,13 +433,16 @@ func windowsAdditionalBrowserArgs() []string {
func browserReachableLoopbackBaseURL(listenAddr string) string { func browserReachableLoopbackBaseURL(listenAddr string) string {
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr)) host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
if err != nil || strings.TrimSpace(port) == "" { if err != nil || strings.TrimSpace(port) == "" {
return "http://" + serverconfig.DefaultBackendListenAddr return "https://localhost:8000"
} }
host = strings.TrimSpace(host) host = strings.TrimSpace(host)
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" { if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
host = "127.0.0.1" 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 相关的逻辑。 // logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
+54 -22
View File
@@ -19,87 +19,119 @@ type usageLookupRecord struct {
CreatedAt time.Time 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 ( const (
dashboardServiceGetTokenUsageProcedure = "/aiserver.v1.DashboardService/GetTokenUsage" dashboardServiceGetTokenUsageProcedure = "/aiserver.v1.DashboardService/GetTokenUsage"
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure = "/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment" dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure = "/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment"
) )
func newAIHandler(service *Service) http.Handler { func newAIHandler(service *Service) *aiHandler {
mux := http.NewServeMux() handler := newAIHandlerMux()
mux.Handle( handler.Handle(
dashboardServiceGetTokenUsageProcedure, dashboardServiceGetTokenUsageProcedure,
connect.NewUnaryHandler(dashboardServiceGetTokenUsageProcedure, service.GetTokenUsage), connect.NewUnaryHandler(dashboardServiceGetTokenUsageProcedure, service.GetTokenUsage),
) )
mux.Handle( handler.Handle(
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure,
connect.NewUnaryHandler(dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, service.GetGlassEarlyPreviewEnrollment), connect.NewUnaryHandler(dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, service.GetGlassEarlyPreviewEnrollment),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceCountTokensProcedure, aiserverv1connect.AiServiceCountTokensProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceCountTokensProcedure, service.CountTokens), connect.NewUnaryHandler(aiserverv1connect.AiServiceCountTokensProcedure, service.CountTokens),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, aiserverv1connect.AiServiceGetThoughtAnnotationProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, service.GetThoughtAnnotation), connect.NewUnaryHandler(aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, service.GetThoughtAnnotation),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, aiserverv1connect.AiServiceWriteGitCommitMessageProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, service.WriteGitCommitMessage), connect.NewUnaryHandler(aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, service.WriteGitCommitMessage),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, aiserverv1connect.AiServiceCreateExperimentalIndexProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, service.CreateExperimentalIndex), connect.NewUnaryHandler(aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, service.CreateExperimentalIndex),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, service.ListExperimentalIndexFiles), connect.NewUnaryHandler(aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, service.ListExperimentalIndexFiles),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceListenExperimentalIndexProcedure, aiserverv1connect.AiServiceListenExperimentalIndexProcedure,
connect.NewServerStreamHandler(aiserverv1connect.AiServiceListenExperimentalIndexProcedure, service.ListenExperimentalIndex), connect.NewServerStreamHandler(aiserverv1connect.AiServiceListenExperimentalIndexProcedure, service.ListenExperimentalIndex),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceRegisterFileToIndexProcedure, aiserverv1connect.AiServiceRegisterFileToIndexProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceRegisterFileToIndexProcedure, service.RegisterFileToIndex), connect.NewUnaryHandler(aiserverv1connect.AiServiceRegisterFileToIndexProcedure, service.RegisterFileToIndex),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, aiserverv1connect.AiServiceSetupIndexDependenciesProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, service.SetupIndexDependencies), connect.NewUnaryHandler(aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, service.SetupIndexDependencies),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, aiserverv1connect.AiServiceComputeIndexTopoSortProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, service.ComputeIndexTopoSort), connect.NewUnaryHandler(aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, service.ComputeIndexTopoSort),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceDocumentationQueryProcedure, aiserverv1connect.AiServiceDocumentationQueryProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceDocumentationQueryProcedure, service.DocumentationQuery), connect.NewUnaryHandler(aiserverv1connect.AiServiceDocumentationQueryProcedure, service.DocumentationQuery),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceAvailableDocsProcedure, aiserverv1connect.AiServiceAvailableDocsProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceAvailableDocsProcedure, service.AvailableDocs), connect.NewUnaryHandler(aiserverv1connect.AiServiceAvailableDocsProcedure, service.AvailableDocs),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, aiserverv1connect.AiServiceKnowledgeBaseAddProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, service.KnowledgeBaseAdd), connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, service.KnowledgeBaseAdd),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseListProcedure, aiserverv1connect.AiServiceKnowledgeBaseListProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseListProcedure, service.KnowledgeBaseList), connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseListProcedure, service.KnowledgeBaseList),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, service.KnowledgeBaseRemove), connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, service.KnowledgeBaseRemove),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, service.KnowledgeBaseUpdate), connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, service.KnowledgeBaseUpdate),
) )
mux.Handle( handler.Handle(
aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure,
connect.NewUnaryHandler(aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, service.FetchRelevantKnowledgeForConversation), connect.NewUnaryHandler(aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, service.FetchRelevantKnowledgeForConversation),
) )
mux.Handle("/", http.NotFoundHandler()) return handler
return mux
} }
func (service *Service) GetThoughtAnnotation(_ context.Context, req *connect.Request[aiserverv1.GetThoughtAnnotationRequest]) (*connect.Response[aiserverv1.GetThoughtAnnotationResponse], error) { func (service *Service) GetThoughtAnnotation(_ context.Context, req *connect.Request[aiserverv1.GetThoughtAnnotationRequest]) (*connect.Response[aiserverv1.GetThoughtAnnotationResponse], error) {
@@ -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")
}
}
+8
View File
@@ -32,3 +32,11 @@ func NewModule(historyRoot string, channelService modeladapter.ChannelResolver)
UploadServiceHandler: newUploadServiceHandler(service), 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)
}
+103 -248
View File
@@ -2,6 +2,8 @@ package backend
import ( import (
"context" "context"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -31,7 +33,7 @@ type Host struct {
listenAddr string listenAddr string
configs *serverconfig.Manager configs *serverconfig.Manager
healthHTTP *http.Client healthHTTP *http.Client
controlPlaneAuth upstream.AuthorizationProvider tlsCertificate *tls.Certificate
runMu sync.RWMutex runMu sync.RWMutex
httpServer *http.Server httpServer *http.Server
@@ -41,7 +43,20 @@ type Host struct {
mux http.Handler 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 { if store == nil {
return nil, fmt.Errorf("backend config store is required") return nil, fmt.Errorf("backend config store is required")
} }
@@ -54,9 +69,16 @@ func NewHost(store *serverconfig.Store, controlPlaneAuth upstream.AuthorizationP
store: store, store: store,
listenAddr: cfg.BackendListenAddr, listenAddr: cfg.BackendListenAddr,
configs: configs, configs: configs,
healthHTTP: newLoopbackHTTPClient(),
controlPlaneAuth: controlPlaneAuth,
} }
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 { if err := host.rebuild(cfg); err != nil {
return nil, err return nil, err
} }
@@ -107,8 +129,15 @@ func (host *Host) BaseURL() string {
if listenAddr == "" { if listenAddr == "" {
return "" return ""
} }
if host.tlsCertificate == nil {
return "http://" + listenAddr 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 { func (host *Host) IsRunning() bool {
if host == nil { if host == nil {
@@ -153,6 +182,12 @@ func (host *Host) Start() error {
host.lastRunErr = fmt.Errorf("监听内置后端 %s 失败: %w", host.listenAddr, err) host.lastRunErr = fmt.Errorf("监听内置后端 %s 失败: %w", host.listenAddr, err)
return host.lastRunErr 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.listenAddr = listener.Addr().String()
host.httpServer = httpServer host.httpServer = httpServer
host.lastRunErr = nil host.lastRunErr = nil
@@ -202,7 +237,7 @@ func (host *Host) HealthCheck(ctx context.Context) error {
} }
client := host.healthHTTP client := host.healthHTTP
if client == nil { if client == nil {
client = newLoopbackHTTPClient() client = newLoopbackHTTPClient(host.tlsCertificate)
} }
response, err := client.Do(request) response, err := client.Do(request)
if err != nil { if err != nil {
@@ -245,9 +280,8 @@ func (host *Host) InProcessHealthCheck() error {
return nil return nil
} }
func newLoopbackHTTPClient() *http.Client { func newLoopbackHTTPClient(certificate *tls.Certificate) *http.Client {
return &http.Client{ transport := &http.Transport{
Transport: &http.Transport{
Proxy: nil, Proxy: nil,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
@@ -257,7 +291,23 @@ func newLoopbackHTTPClient() *http.Client {
MaxIdleConns: 1, MaxIdleConns: 1,
MaxIdleConnsPerHost: 1, MaxIdleConnsPerHost: 1,
IdleConnTimeout: 30 * time.Second, 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: transport,
} }
} }
@@ -276,8 +326,20 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
SystemSettingService: &serverSystemSettings{configs: host.configs}, SystemSettingService: &serverSystemSettings{configs: host.configs},
HTTPClient: netproxy.NewHTTPClient(30000 * time.Second), 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.Use(
server.Recover(), server.Recover(),
server.ServerContext(), server.ServerContext(),
@@ -427,19 +489,11 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
})), })),
), ),
server.POST("/oauth/token", server.GET("/auth/cursor_dev_session_token",
server.Name("oauth_token"), server.Name("auth_cursor_dev_session_token"),
server.HTTP(), server.HTTP(),
server.Local(upstream.MockOAuthAction(routeDeps, upstream.CompatRouteConfig{ server.Local(upstream.MockDevSessionTokenAction(routeDeps, upstream.CompatRouteConfig{
Name: "oauth_token", Name: "auth_cursor_dev_session_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",
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
})), })),
), ),
@@ -476,17 +530,14 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
server.Any("/aiserver.v1.AiService/*", server.Any("/aiserver.v1.AiService/*",
server.Name("ai_service"), server.Name("ai_service"),
server.HTTP(), 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/AvailableModels", "cpp_available_models", server.ConnectUnary(), routeDeps),
tabServerProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps), tabServerProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps),
server.Any("/aiserver.v1.CppService/*", server.Any("/aiserver.v1.CppService/*",
server.Name("cpp_service"), server.Name("cpp_service"),
server.HTTP(), server.HTTP(),
server.Local(func(ctx *server.Context) error { server.Local(fallbackForward),
http.NotFound(ctx.Writer, ctx.Request)
return nil
}),
), ),
tabServerProcedure("/aiserver.v1.FileSyncService/FSSyncFile", "file_sync_sync_file", server.ConnectUnary(), routeDeps), 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), 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.Any("/aiserver.v1.FileSyncService/*",
server.Name("file_sync"), server.Name("file_sync"),
server.HTTP(), server.HTTP(),
server.Local(func(ctx *server.Context) error { server.Local(fallbackForward),
http.NotFound(ctx.Writer, ctx.Request)
return nil
}),
), ),
server.POST("/aiserver.v1.DashboardService/GetTokenUsage", server.POST("/aiserver.v1.DashboardService/GetTokenUsage",
server.Name("dashboard_token_usage"), server.Name("dashboard_token_usage"),
@@ -530,21 +578,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.DashboardTeamsMockBuilder, 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.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam",
server.Name("dashboard_get_team_admin_settings_or_empty"), server.Name("dashboard_get_team_admin_settings_or_empty"),
server.ConnectUnary(), server.ConnectUnary(),
@@ -565,76 +598,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.EmptyMockBuilder, 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.POST("/aiserver.v1.DashboardService/GetPlanInfo",
server.Name("dashboard_plan_info"), server.Name("dashboard_plan_info"),
server.ConnectUnary(), server.ConnectUnary(),
@@ -665,104 +628,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
MockBuilder: upstream.DashboardIsOnNewPricingMockBuilder, MockBuilder: upstream.DashboardIsOnNewPricingMockBuilder,
})), })),
), ),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMarketplace", "dashboard_add_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps), server.Any("/*",
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/AddMcpServersFromPlugin", "dashboard_add_mcp_servers_from_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps), server.Name("upstream_fallback"),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/BatchGetPluginMcpConfig", "dashboard_batch_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetAvailableMcpServers", "dashboard_get_available_mcp_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPlugin", "dashboard_get_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/GetPluginMcpConfig", "dashboard_get_plugin_mcp_config", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/InstallUserPlugin", "dashboard_install_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplacePlugins", "dashboard_list_marketplace_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListMarketplaces", "dashboard_list_marketplaces", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ListUserPluginInstalls", "dashboard_list_user_plugin_installs", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RefreshMarketplace", "dashboard_refresh_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins", "dashboard_register_marketplace_and_plugins", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/RemoveMarketplace", "dashboard_remove_marketplace", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/ResolvePluginsByRef", "dashboard_resolve_plugins_by_ref", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UninstallUserPlugin", "dashboard_uninstall_user_plugin", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.DashboardService/UpdateUserPluginInstall", "dashboard_update_user_plugin_install", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
cursorControlPlaneProcedure("/aiserver.v1.MCPRegistryService/GetKnownServers", "mcp_registry_get_known_servers", server.ConnectUnary(), host.controlPlaneAuth, routeDeps),
server.Any("/aiserver.v1.DashboardService/*",
server.Name("dashboard"),
server.HTTP(), server.HTTP(),
server.Local(func(ctx *server.Context) error { server.Local(fallbackForward),
http.NotFound(ctx.Writer, ctx.Request)
return nil
}),
), ),
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 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 { func repositoryServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module) server.Option {
localAction := server.HTTPHandlerAction(module.RepositoryServiceHandler) localAction := server.HTTPHandlerAction(module.RepositoryServiceHandler)
return server.POST(pattern, 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 { type serverSystemSettings struct {
configs *serverconfig.Manager configs *serverconfig.Manager
} }
+175
View File
@@ -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)
}
}
+127
View File
@@ -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)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
) )
const ( const (
DefaultBackendListenAddr = "127.0.0.1:18090" DefaultBackendListenAddr = "127.0.0.1:8000"
DefaultProxyListenAddr = "127.0.0.1:18080" DefaultProxyListenAddr = "127.0.0.1:18080"
DefaultFrontendBaseURL = "http://127.0.0.1" DefaultFrontendBaseURL = "http://127.0.0.1"
DefaultProviderStreamIdleTimeoutSeconds = 240 DefaultProviderStreamIdleTimeoutSeconds = 240
+21 -87
View File
@@ -14,12 +14,13 @@ import (
type CompatRouteConfig struct { type CompatRouteConfig struct {
Name string Name string
StatusCode int StatusCode int
JSONBody map[string]any
MockProtoType string MockProtoType string
MockBuilder func(*RequestContext) (map[string]any, error) MockBuilder func(*RequestContext) (map[string]any, error)
ConsoleLog bool ConsoleLog bool
} }
const DefaultCursorUpstreamBaseURL = "https://api2.cursor.sh:443"
func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc { func ForwardAction(deps Dependencies, cfg CompatRouteConfig) server.HandlerFunc {
return func(ctx *server.Context) error { return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) 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 // FallbackForwardAction preserves an MITM request's original upstream URL. A
// independent desktop account after the local-mode identity rewrite has run. // native request has no original host metadata, so it is resolved against the
func AuthenticatedForwardAction(deps Dependencies, cfg CompatRouteConfig, authorizationProvider AuthorizationProvider) server.HandlerFunc { // 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 { return func(ctx *server.Context) error {
reqCtx, _, err := newCompatRouteObjects(ctx, deps, cfg) if ctx == nil || ctx.Request == nil || ctx.Request.URL == nil {
return fmt.Errorf("fallback upstream request context is invalid")
}
if ctx.UpstreamURL == nil {
baseURL, err := ParseAndValidateRawURL(defaultBaseURL)
if err != nil { if err != nil {
return err return fmt.Errorf("parse fallback upstream URL: %w", err)
} }
if reqCtx == nil || reqCtx.Request == nil { targetURL := *ctx.Request.URL
return fmt.Errorf("Cursor 控制面请求上下文无效") targetURL.Scheme = baseURL.Scheme
targetURL.Host = baseURL.Host
targetURL.User = baseURL.User
ctx.UpstreamURL = &targetURL
} }
if authorizationProvider == nil { return forward(ctx)
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
} }
} }
@@ -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 { return func(ctx *server.Context) error {
reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg) reqCtx, route, err := newCompatRouteObjects(ctx, deps, cfg)
if err != nil { if err != nil {
return err return err
} }
return handleMockJSON(reqCtx, route) return handleMockDevSessionToken(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)
} }
} }
@@ -169,7 +116,6 @@ func newCompatRouteObjects(ctx *server.Context, deps Dependencies, cfg CompatRou
Name: cfg.Name, Name: cfg.Name,
Pattern: ctx.Request.URL.Path, Pattern: ctx.Request.URL.Path,
StatusCode: cfg.StatusCode, StatusCode: cfg.StatusCode,
JSONBody: cfg.JSONBody,
MockProtoType: cfg.MockProtoType, MockProtoType: cfg.MockProtoType,
MockPayloadBuilder: cfg.MockBuilder, MockPayloadBuilder: cfg.MockBuilder,
ConsoleLog: cfg.ConsoleLog, ConsoleLog: cfg.ConsoleLog,
@@ -221,10 +167,6 @@ func DashboardTeamsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardTeamsPayload(reqCtx) return buildDashboardTeamsPayload(reqCtx)
} }
func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardManagedSkillsPayload(reqCtx)
}
// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту // EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту
// достаточно успешного "пусто": нет team-настроек, нет репозиториев, // достаточно успешного "пусто": нет team-настроек, нет репозиториев,
// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки. // нет маркетплейсов/плагинов/команд, телеметрия принята без обработки.
@@ -237,14 +179,6 @@ func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return map[string]any{"success": true}, nil 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) { func DashboardPlanInfoMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
return buildDashboardPlanInfoPayload(reqCtx) return buildDashboardPlanInfoPayload(reqCtx)
} }
+193
View File
@@ -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)
}
@@ -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),
}
}
+4 -159
View File
@@ -2,23 +2,18 @@ package upstream
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/big"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"cursor/gen/agentv1" "cursor/gen/agentv1"
"cursor/gen/aiserverv1" "cursor/gen/aiserverv1"
"cursor/internal/logger" "cursor/internal/logger"
"cursor/internal/netproxy" "cursor/internal/netproxy"
legacyruntime "cursor/internal/runtime"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@@ -87,14 +82,6 @@ func buildUpstreamRequest(reqCtx *RequestContext, body []byte, options ForwardOp
} }
upstreamRequest.Host = reqCtx.TargetURL.Host 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 { if options.PatchHeaders != nil {
options.PatchHeaders(upstreamRequest.Header) options.PatchHeaders(upstreamRequest.Header)
} }
@@ -167,61 +154,21 @@ func copyRequestHeadersForUpstream(target http.Header, source http.Header) {
} }
func copyResponseHeadersToClient(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 { for key, values := range source {
lowerKey := strings.ToLower(key) lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists { if _, exists := hopByHopHeaders[lowerKey]; exists {
continue continue
} }
if localWildcardCORS && (lowerKey == "access-control-allow-origin" || lowerKey == "access-control-allow-credentials") {
continue
}
for _, value := range values { for _, value := range values {
target.Add(key, value) 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 { func shouldRequestCarryBody(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) { switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead, http.MethodDelete: case http.MethodGet, http.MethodHead, http.MethodDelete:
@@ -238,17 +185,6 @@ func marshalJSONBody(payload map[string]any) ([]byte, error) {
return json.Marshal(payload) 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 { func handleMockProto(reqCtx *RequestContext, route *Route) error {
payload := map[string]any{} payload := map[string]any{}
if route.MockPayloadBuilder != nil { if route.MockPayloadBuilder != nil {
@@ -270,91 +206,6 @@ func handleMockProto(reqCtx *RequestContext, route *Route) error {
return nil 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 { func appendProtoVarint(output []byte, value uint64) []byte {
for value >= 0x80 { for value >= 0x80 {
output = append(output, byte(value)|0x80) output = append(output, byte(value)|0x80)
@@ -431,8 +282,6 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetTeamAdminSettingsResponse{}, nil return &aiserverv1.GetTeamAdminSettingsResponse{}, nil
case "aiserver.v1.GetTeamReposResponse": case "aiserver.v1.GetTeamReposResponse":
return &aiserverv1.GetTeamReposResponse{}, nil return &aiserverv1.GetTeamReposResponse{}, nil
case "aiserver.v1.ListMarketplacesResponse":
return &aiserverv1.ListMarketplacesResponse{}, nil
case "aiserver.v1.GetUsableModelsResponse": case "aiserver.v1.GetUsableModelsResponse":
return &agentv1.GetUsableModelsResponse{}, nil return &agentv1.GetUsableModelsResponse{}, nil
case "aiserver.v1.GetDefaultModelForCliResponse": case "aiserver.v1.GetDefaultModelForCliResponse":
@@ -441,10 +290,6 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.GetDefaultModelResponse{}, nil return &aiserverv1.GetDefaultModelResponse{}, nil
case "aiserver.v1.GetGlobalCommandsResponse": case "aiserver.v1.GetGlobalCommandsResponse":
return &aiserverv1.GetGlobalCommandsResponse{}, nil return &aiserverv1.GetGlobalCommandsResponse{}, nil
case "aiserver.v1.GetEffectiveUserPluginsResponse":
return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil
case "aiserver.v1.RegisterMarketplaceAndPluginsResponse":
return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil
case "aiserver.v1.GetCliDownloadUrlResponse": case "aiserver.v1.GetCliDownloadUrlResponse":
return &aiserverv1.GetCliDownloadUrlResponse{}, nil return &aiserverv1.GetCliDownloadUrlResponse{}, nil
case "aiserver.v1.SubmitLogsResponse": case "aiserver.v1.SubmitLogsResponse":
@@ -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)
}
}
+43 -58
View File
@@ -24,9 +24,7 @@ const (
// файловых инструментов падают с "[unimplemented] HTTP 404". // файловых инструментов падают с "[unimplemented] HTTP 404".
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579" localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
localUltraMembershipType = "ultra"
localUltraPaymentID = "local_ultra" localUltraPaymentID = "local_ultra"
localUltraSubscriptionStatus = "active"
localUltraPlanIncludedCents = 20000 localUltraPlanIncludedCents = 20000
localUltraDashboardUserID = 1 localUltraDashboardUserID = 1
localUltraBillingCycleDuration = 30 * 24 * time.Hour localUltraBillingCycleDuration = 30 * 24 * time.Hour
@@ -432,6 +430,7 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) {
func buildServerConfigPayload(*RequestContext) (map[string]any, error) { func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
return map[string]any{ 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", "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
"cliSandboxDefaultEnabled": true, "cliSandboxDefaultEnabled": true,
"indexingConfig": map[string]any{ "indexingConfig": map[string]any{
@@ -547,26 +546,29 @@ func buildFirstWindowStatsigDecisionPayload(*RequestContext) (map[string]any, er
}, nil }, 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() billingCycleStart := time.Now().Add(-localUltraBillingCycleDuration).UnixMilli()
billingCycleEnd := time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli() billingCycleEnd := time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli()
displayMessage := planName + " active"
return map[string]any{ return map[string]any{
"autoModelSelectedDisplayMessage": "Ultra plan active", "autoModelSelectedDisplayMessage": displayMessage,
"billingCycleEnd": billingCycleEnd, "billingCycleEnd": billingCycleEnd,
"billingCycleStart": billingCycleStart, "billingCycleStart": billingCycleStart,
"displayMessage": "Ultra plan active", "displayMessage": displayMessage,
"displayThreshold": 99999999, "displayThreshold": 99999999,
"enabled": true, "enabled": true,
"namedModelSelectedDisplayMessage": "Ultra plan active", "namedModelSelectedDisplayMessage": displayMessage,
"planUsage": map[string]any{ "planUsage": map[string]any{
"apiPercentUsed": 0, "apiPercentUsed": 0,
"apiSpend": 0, "apiSpend": 0,
"autoPercentUsed": 0, "autoPercentUsed": 0,
"autoSpend": 0, "autoSpend": 0,
"bonusTooltip": "Ultra local account mock is active.", "bonusTooltip": "Local account mock is active.",
"includedSpend": localUltraPlanIncludedCents, "includedSpend": includedSpend,
"limit": localUltraPlanIncludedCents, "limit": includedSpend,
"remaining": localUltraPlanIncludedCents, "remaining": includedSpend,
"remainingBonus": false, "remainingBonus": false,
"totalPercentUsed": 0, "totalPercentUsed": 0,
"totalSpend": 0, "totalSpend": 0,
@@ -577,62 +579,45 @@ func buildDashboardCurrentPeriodUsagePayload(*RequestContext) (map[string]any, e
}, nil }, 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{ return map[string]any{
"teams": []map[string]any{}, "teams": []map[string]any{},
}, nil }, nil
} }
func buildDashboardManagedSkillsPayload(*RequestContext) (map[string]any, error) { func buildDashboardPlanInfoPayload(reqCtx *RequestContext) (map[string]any, error) {
return map[string]any{ plan := localDevPlanFromRequest(reqCtx)
"skills": []map[string]any{}, planName, includedAmountCents := localDevPlanDetails(plan)
}, nil 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"
} }
func buildDashboardGetMePayload(reqCtx *RequestContext) (map[string]any, error) {
authID := ""
if reqCtx != nil {
authID = authIDFromBearer(reqCtx.Headers.Get("authorization"))
}
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{ return map[string]any{
"planInfo": map[string]any{ "planInfo": map[string]any{
"planName": "Ultra Plan", "planName": planName,
"includedAmountCents": localUltraPlanIncludedCents, "includedAmountCents": includedAmountCents,
"price": "$200/mo", "price": price,
"billingCycleEnd": time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli(), "billingCycleEnd": time.Now().Add(10 * 365 * 24 * time.Hour).UnixMilli(),
}, },
}, nil }, nil
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"cursor/gen/agentv1" "cursor/gen/agentv1"
"cursor/gen/aiserverv1"
legacyruntime "cursor/internal/runtime" legacyruntime "cursor/internal/runtime"
"google.golang.org/protobuf/proto" "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) { func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) {
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id") payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
if err != nil { if err != nil {
@@ -20,13 +20,6 @@ type SystemSettingService interface {
ResolveModelAdapters(context.Context) ([]legacyruntime.ModelAdapterConfig, error) 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 { type HTTPClient interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
} }
@@ -91,7 +84,6 @@ type Route struct {
Matcher Matcher Matcher Matcher
ConsoleLog bool ConsoleLog bool
StatusCode int StatusCode int
JSONBody map[string]any
MockProtoType string MockProtoType string
MockPayloadBuilder func(*RequestContext) (map[string]any, error) MockPayloadBuilder func(*RequestContext) (map[string]any, error)
Handler RouteHandler Handler RouteHandler
-18
View File
@@ -30,9 +30,6 @@ type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest
// ModelAdapterModelsResult 定义模型列表查询结果。 // ModelAdapterModelsResult 定义模型列表查询结果。
type ModelAdapterModelsResult = client.ModelAdapterModelsResult type ModelAdapterModelsResult = client.ModelAdapterModelsResult
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
type CursorAccountStatus = client.CursorAccountStatus
// LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。 // LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。
type LicenseActionRequest = client.LicenseActionRequest type LicenseActionRequest = client.LicenseActionRequest
@@ -100,21 +97,6 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
return s.core.SaveUserConfig(cfg) 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 相关的逻辑。 // TestModelAdapter 用于处理与 TestModelAdapter 相关的逻辑。
func (s *ProxyService) TestModelAdapter(adapter ModelAdapterConfig) (ModelAdapterTestResult, error) { func (s *ProxyService) TestModelAdapter(adapter ModelAdapterConfig) (ModelAdapterTestResult, error) {
return s.core.TestModelAdapter(adapter) return s.core.TestModelAdapter(adapter)
-35
View File
@@ -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()
}
+2 -9
View File
@@ -11,7 +11,6 @@ import (
"cursor/internal/logger" "cursor/internal/logger"
"cursor/internal/mitm" "cursor/internal/mitm"
"cursor/internal/netproxy" "cursor/internal/netproxy"
localruntime "cursor/internal/runtime"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
) )
@@ -85,11 +84,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
if err := s.ensureProxy(cfg); err != nil { if err := s.ensureProxy(cfg); err != nil {
return fail("ensure_proxy", err) return fail("ensure_proxy", err)
} }
if err := cursor.DisableCursorStatsigGates(); err != nil {
// 启动时注入账号信息 logger.Errorf("disableCursorStatsigGates failed: %v", err)
if err := cursor.InjectCursorUserInfo(localruntime.InjectAccountEmail, localruntime.InjectAuthToken); err != nil {
logger.Errorf("injectCursorUserInfo failed: %v", err)
// 不阻断启动,仅记录日志
} }
if s.proxy != nil && !s.proxy.IsRunning() { if s.proxy != nil && !s.proxy.IsRunning() {
@@ -265,9 +261,6 @@ func (s *ProxyService) ShutdownForQuit() {
finalErr = errors.Join(finalErr, err) finalErr = errors.Join(finalErr, err)
} }
} }
if s.cursorAccount != nil {
s.cursorAccount.Shutdown()
}
if finalErr != nil { if finalErr != nil {
s.setLastError(finalErr) s.setLastError(finalErr)
} }
+14 -10
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"path/filepath"
"sync" "sync"
"time" "time"
@@ -12,7 +11,6 @@ import (
backend "cursor/internal/backend" backend "cursor/internal/backend"
serverconfig "cursor/internal/backend/server/config" serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs" "cursor/internal/certs"
"cursor/internal/cursoraccount"
"cursor/internal/logger" "cursor/internal/logger"
"cursor/internal/mitm" "cursor/internal/mitm"
"cursor/internal/netproxy" "cursor/internal/netproxy"
@@ -37,8 +35,6 @@ type ProxyService struct {
certManager *certs.Manager certManager *certs.Manager
// backendHost 表示当前嵌入式 backend 服务。 // backendHost 表示当前嵌入式 backend 服务。
backendHost *backend.Host backendHost *backend.Host
// cursorAccount 持有仅供插件、Skills 和 MCP 控制面使用的真实 Cursor 身份。
cursorAccount *cursoraccount.Manager
// mu 表示当前声明中的 mu。 // mu 表示当前声明中的 mu。
mu sync.RWMutex mu sync.RWMutex
@@ -88,12 +84,8 @@ func NewProxyService(proxy *mitm.ProxyServer, certManager *certs.Manager, caCert
publicClient: netproxy.NewHTTPClient(publicAPITimeout), publicClient: netproxy.NewHTTPClient(publicAPITimeout),
modelTestResults: make(map[string]ModelAdapterTestResult), 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) service.store = serverconfig.NewStore(service.configPath, service.logsRoot)
host, err := backend.NewHost(service.store, service.cursorAccount) host, err := service.newBackendHost()
if err != nil { if err != nil {
logger.Errorf("init backend host failed: %v", err) logger.Errorf("init backend host failed: %v", err)
} else { } else {
@@ -109,7 +101,7 @@ func (s *ProxyService) ensureBackendHost() error {
if s.backendHost != nil { if s.backendHost != nil {
return nil return nil
} }
host, err := backend.NewHost(s.store, s.cursorAccount) host, err := s.newBackendHost()
if err != nil { if err != nil {
return err return err
} }
@@ -117,6 +109,18 @@ func (s *ProxyService) ensureBackendHost() error {
return nil 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 { func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error {
if s == nil { if s == nil {
return nil return nil
+55
View File
@@ -60,6 +60,23 @@ func InjectCursorUserInfo(email, token string) error {
return nil 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 { func buildCursorAuthStateValues(email, token string) map[string]string {
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
token = strings.TrimSpace(token) token = strings.TrimSpace(token)
@@ -131,6 +148,44 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
return nil 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 { func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
var raw []byte var raw []byte
err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw) err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw)
+49
View File
@@ -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 { func readCursorStatsigBootstrapForTest(t *testing.T, path string) []byte {
t.Helper() t.Helper()
db, err := sql.Open("sqlite", path) db, err := sql.Open("sqlite", path)
-589
View File
@@ -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")
}
+14
View File
@@ -184,6 +184,19 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage
return nil, err 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{ s := &ProxyServer{
addr: addr, addr: addr,
baseURL: normalizedBaseURL, baseURL: normalizedBaseURL,
@@ -198,6 +211,7 @@ func NewProxyServer(addr, baseURL, _ string, _ string, certManager *certs.Manage
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 60 * time.Second, ResponseHeaderTimeout: 60 * time.Second,
TLSClientConfig: tlsConfig,
}, },
}, },
} }