// 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) }