Files
Local-Cursor/internal/cursor/cli.go
FuDie0915 db73cf5a39 chore: import Local-Cursor v1.1.8 source snapshot
Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage.
Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
2026-11-10 01:24:28 +08:00

418 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// cli.go 管理 CLI 版 cursor-agent 的代理设置注入。
// 通过在数据目录生成 wrapper 脚本和 preload 脚本,
// 将 CLI 的 cursor.sh 连接重定向到后端 HTTPS+HTTP/2 监听器。
package cursor
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"cursor/internal/appdata"
"cursor/internal/logger"
localruntime "cursor/internal/runtime"
)
// CLIWrapperDir 返回 wrapper 脚本存放目录。
func CLIWrapperDir() string {
return filepath.Join(appdata.RootDir(), "cli")
}
// CLIWrapperPath 返回当前平台的 wrapper 脚本完整路径。
func CLIWrapperPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(CLIWrapperDir(), "agent.cmd")
}
return filepath.Join(CLIWrapperDir(), "agent")
}
// DetectCLIInstallPath 尝试检测 CLI 版 cursor-agent 的安装路径。
func DetectCLIInstallPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := make([]string, 0, 4)
switch runtime.GOOS {
case "windows":
localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if localAppData == "" {
localAppData = filepath.Join(homeDir, "AppData", "Local")
}
candidates = append(candidates,
filepath.Join(localAppData, "cursor-agent", "agent.cmd"),
)
case "darwin":
candidates = append(candidates,
filepath.Join(homeDir, ".cursor-agent", "agent"),
filepath.Join(homeDir, ".local", "bin", "cursor-agent"),
)
case "linux":
candidates = append(candidates,
filepath.Join(homeDir, ".cursor-agent", "agent"),
filepath.Join(homeDir, ".local", "bin", "cursor-agent"),
"/usr/local/bin/cursor-agent",
)
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c
}
}
return ""
}
// cliPreloadFilename 是 preload 脚本的文件名。
const cliPreloadFilename = "tls-bypass.cjs"
// cliPreloadScript 是注入到 Node.js 的 preload 脚本。
//
// 工作原理:
// 1. patch net.connect — 将 cursor.sh 域名的 TCP 连接重定向到本地后端 HTTPS 端口
// 2. patch tls.connect — 强制 rejectUnauthorized=false,使后端签发的 CA 证书被接受
//
// CLI 的 connect-node 使用 HTTP/2 (http2.connect),goproxy 无法 MITM HTTP/2 帧。
// 因此跳过 MITM 代理,直接在 TCP 层重定向到后端的 HTTPS+HTTP/2 监听器。
// 后端 Go http.Server.ServeTLS 原生支持 HTTP/2 ALPN。
const cliPreloadScript = `// Generated by Local-Curosr: redirect cursor.sh TCP to local HTTPS backend
'use strict';
const tls = require('tls');
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const logPath = path.join(os.homedir(), '.cursor-local-assistant-v2', 'logs', 'tls-bypass.log');
function dbg(msg) {
try { fs.appendFileSync(logPath, new Date().toISOString() + ' ' + msg + '\n'); } catch(e) {}
}
var REDIRECT_PORT = parseInt(process.env.CLI_HTTPS_PORT || '39092', 10);
var REDIRECT_HOST = '127.0.0.1';
var REDIRECT_DOMAINS = ['api2.cursor.sh', 'api3.cursor.sh'];
function shouldRedirect(host) {
if (!host) return false;
host = String(host).toLowerCase().trim();
for (var i = 0; i < REDIRECT_DOMAINS.length; i++) {
var d = REDIRECT_DOMAINS[i];
if (host === d || host.endsWith('.' + d)) return true;
}
return false;
}
// --- patch net.connect: TCP 层重定向 ---
var origNetConnect = net.connect;
function patchedNetConnect() {
if (typeof arguments[0] === 'object' && arguments[0] !== null) {
var opts = arguments[0];
if (shouldRedirect(opts.host)) {
dbg('net.connect redirect: ' + opts.host + ':' + opts.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
opts.host = REDIRECT_HOST;
opts.port = REDIRECT_PORT;
}
} else if (typeof arguments[0] === 'number' && typeof arguments[1] === 'string') {
if (shouldRedirect(arguments[1])) {
dbg('net.connect redirect (positional): ' + arguments[1] + ':' + arguments[0] + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
arguments[1] = REDIRECT_HOST;
arguments[0] = REDIRECT_PORT;
}
}
return origNetConnect.apply(this, arguments);
}
net.connect = patchedNetConnect;
net.createConnection = patchedNetConnect;
// --- patch tls.connect: 强制 rejectUnauthorized=false + 重定向 ---
var origTLSConnect = tls.connect;
tls.connect = function() {
for (var i = 0; i < arguments.length && i < 3; i++) {
if (arguments[i] && typeof arguments[i] === 'object' && !Array.isArray(arguments[i])) {
var o = arguments[i];
o.rejectUnauthorized = false;
if (o.host && shouldRedirect(o.host)) {
dbg('tls.connect redirect: ' + o.host + ':' + o.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
o.host = REDIRECT_HOST;
o.port = REDIRECT_PORT;
if (!o.servername) o.servername = o.host;
}
break;
}
}
return origTLSConnect.apply(this, arguments);
};
dbg('tls-bypass preload loaded, redirect=' + REDIRECT_HOST + ':' + REDIRECT_PORT);
`
// writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。
func writeCLIPreloadScript() (string, error) {
wrapperDir := CLIWrapperDir()
if err := os.MkdirAll(wrapperDir, 0o755); err != nil {
return "", fmt.Errorf("创建 preload 目录失败: %w", err)
}
preloadPath := filepath.Join(wrapperDir, cliPreloadFilename)
if err := os.WriteFile(preloadPath, []byte(cliPreloadScript), 0o644); err != nil {
return "", fmt.Errorf("写入 preload 脚本失败: %w", err)
}
return preloadPath, nil
}
// WriteCLIProxyWrapper 在数据目录生成 wrapper 脚本。
//
// wrapper 设置环境变量后调用真实 CLI:
// - CLI_HTTPS_PORT → 后端 HTTPS+HTTP/2 监听端口(preload 脚本读取)
// - NODE_EXTRA_CA_CERTS → CA 证书路径
// - NODE_OPTIONS → --require preload 脚本,patch net.connect/tls.connect
// - CURSOR_AUTH_TOKEN → 注入 token,跳过浏览器登录
//
// 不再设置 HTTP_PROXY/HTTPS_PROXY — 跳过 goproxy MITM,
// 由 preload 脚本在 TCP 层直接重定向到后端 HTTPS 监听器。
func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
cliHTTPSAddr = strings.TrimSpace(cliHTTPSAddr)
caCertPath = strings.TrimSpace(caCertPath)
if cliHTTPSAddr == "" {
return fmt.Errorf("CLI HTTPS 监听地址为空")
}
// 提取端口号
_, port, err := net.SplitHostPort(cliHTTPSAddr)
if err != nil {
return fmt.Errorf("解析 CLI HTTPS 监听地址失败: %w", err)
}
cliPath := DetectCLIInstallPath()
if cliPath == "" {
return fmt.Errorf("未检测到 CLI 版 cursor-agent 安装路径")
}
wrapperDir := CLIWrapperDir()
if err := os.MkdirAll(wrapperDir, 0o755); err != nil {
return fmt.Errorf("创建 wrapper 目录失败: %w", err)
}
preloadPath, err := writeCLIPreloadScript()
if err != nil {
return err
}
authToken := localruntime.InjectAuthToken
var content string
if runtime.GOOS == "windows" {
content = buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath)
} else {
content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath)
}
wrapperPath := CLIWrapperPath()
if err := os.WriteFile(wrapperPath, []byte(content), 0o755); err != nil {
return fmt.Errorf("写入 wrapper 脚本失败: %w", err)
}
logger.Infof(
"writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s",
wrapperPath, port, caCertPath, cliPath, preloadPath,
)
return nil
}
// ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。
func ClearCLIProxyWrapper() error {
wrapperPath := CLIWrapperPath()
if _, err := os.Stat(wrapperPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(wrapperPath); err != nil {
return fmt.Errorf("移除 wrapper 脚本失败: %w", err)
}
preloadPath := filepath.Join(CLIWrapperDir(), cliPreloadFilename)
if _, err := os.Stat(preloadPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(preloadPath); err != nil {
return fmt.Errorf("移除 preload 脚本失败: %w", err)
}
// 清理 Windows 终端初始化批处理脚本
terminalInitPath := filepath.Join(CLIWrapperDir(), "cli-terminal-init.bat")
if _, err := os.Stat(terminalInitPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(terminalInitPath); err != nil {
return fmt.Errorf("移除终端初始化脚本失败: %w", err)
}
logger.Infof("clearCLIProxyWrapper: removed wrapper=%s preload=%s terminal_init=%s", wrapperPath, preloadPath, terminalInitPath)
return nil
}
// CLIWrapperExists 检查 wrapper 是否已存在。
func CLIWrapperExists() bool {
_, err := os.Stat(CLIWrapperPath())
return err == nil
}
// CLIAuthFilePath 返回 CLI 版 auth.json 的路径。
// Windows: %APPDATA%\Cursor\auth.json; Unix: ~/.cursor/auth.json
func CLIAuthFilePath() string {
switch runtime.GOOS {
case "windows":
appData := strings.TrimSpace(os.Getenv("APPDATA"))
if appData == "" {
homeDir, _ := os.UserHomeDir()
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "auth.json")
default:
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".cursor", "auth.json")
}
}
// cliAuthData 对应 CLI auth.json 的 JSON 结构。
type cliAuthData struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
APIKey *string `json:"apiKey"`
BedrockCredentials *string `json:"bedrockCredentials"`
}
// cliAuthBackupPath 返回真实凭据的备份路径(auth.json.local-cursor-backup)。
func cliAuthBackupPath() string {
return CLIAuthFilePath() + ".local-cursor-backup"
}
// WriteCLIAuthFile 写入 mock auth.json,使 CLI 的 FileCredentialStore.getAllCredentials() 通过认证检查。
// accessToken/refreshToken 使用 InjectAuthToken(有效 JWT,exp 在 2099 年)。
// 覆盖前会备份用户的真实凭据(仅首次,之后保留最早那份原始备份),
// ClearCLIAuthFile 时若存在备份则恢复,避免销毁用户真实登录态。
func WriteCLIAuthFile() error {
authPath := CLIAuthFilePath()
dir := filepath.Dir(authPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建 auth.json 目录失败: %w", err)
}
// 首次覆盖前备份真实凭据;备份已存在说明此前已备份过,不覆盖最早的原件。
backupPath := cliAuthBackupPath()
if raw, err := os.ReadFile(authPath); err == nil {
if _, statErr := os.Stat(backupPath); os.IsNotExist(statErr) {
if isMockAuthData(raw) {
logger.Infof("writeCLIAuthFile: existing auth.json is mock, skip backup path=%s", authPath)
} else if writeErr := os.WriteFile(backupPath, raw, 0o600); writeErr != nil {
return fmt.Errorf("备份原 auth.json 失败: %w", writeErr)
} else {
logger.Infof("writeCLIAuthFile: backed up original auth.json to %s", backupPath)
}
}
}
data := cliAuthData{
AccessToken: localruntime.InjectAuthToken,
RefreshToken: localruntime.InjectAuthToken,
APIKey: nil,
BedrockCredentials: nil,
}
content, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("序列化 auth.json 失败: %w", err)
}
if err := os.WriteFile(authPath, content, 0o600); err != nil {
return fmt.Errorf("写入 auth.json 失败: %w", err)
}
logger.Infof("writeCLIAuthFile: path=%s", authPath)
return nil
}
// isMockAuthData 判断 auth.json 内容是否为本工具写入的 mock 凭据。
func isMockAuthData(raw []byte) bool {
var data cliAuthData
if err := json.Unmarshal(raw, &data); err != nil {
return false
}
return data.AccessToken == localruntime.InjectAuthToken
}
// ClearCLIAuthFile 移除 mock auth.json。
// 若存在真实凭据备份则恢复之;仅当 auth.json 是 mock 内容时才删除。
func ClearCLIAuthFile() error {
authPath := CLIAuthFilePath()
raw, err := os.ReadFile(authPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
backupPath := cliAuthBackupPath()
backupExists := false
if _, statErr := os.Stat(backupPath); statErr == nil {
backupExists = true
}
if isMockAuthData(raw) {
if backupExists {
// 恢复用户真实凭据。
if restoreErr := os.Rename(backupPath, authPath); restoreErr != nil {
return fmt.Errorf("恢复原 auth.json 失败: %w", restoreErr)
}
logger.Infof("clearCLIAuthFile: restored original auth.json from %s", backupPath)
return nil
}
if err := os.Remove(authPath); err != nil {
return fmt.Errorf("移除 auth.json 失败: %w", err)
}
logger.Infof("clearCLIAuthFile: removed mock auth.json path=%s", authPath)
return nil
}
// 内容不是 mock(用户在此期间重新登录过),保留现状并不动备份。
logger.Infof("clearCLIAuthFile: auth.json is not mock, leave untouched path=%s", authPath)
return nil
}
func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
cliDir := filepath.Dir(cliPath)
psScript := filepath.Join(cliDir, "cursor-agent.ps1")
return fmt.Sprintf(`@echo off
setlocal
set "HTTP_PROXY="
set "HTTPS_PROXY="
set "CLI_HTTPS_PORT=%s"
set "NODE_EXTRA_CA_CERTS=%s"
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
set "NODE_OPTIONS=--require=%s"
set "CURSOR_AUTH_TOKEN=%s"
set "CURSOR_INVOKED_AS=agent.cmd"
%%SystemRoot%%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%s" %%*
`, port, caCertPath, preloadPath, authToken, psScript)
}
func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
return fmt.Sprintf(`#!/bin/sh
unset HTTP_PROXY
unset HTTPS_PROXY
export CLI_HTTPS_PORT="%s"
export NODE_EXTRA_CA_CERTS="%s"
export NODE_TLS_REJECT_UNAUTHORIZED=0
export NODE_OPTIONS="--require=%s"
export CURSOR_AUTH_TOKEN="%s"
exec "%s" "$@"
`, port, caCertPath, preloadPath, authToken, cliPath)
}