// cli.go 管理 CLI 版 cursor-agent 的代理设置注入。 // 通过在数据目录生成 wrapper 脚本和 preload 脚本, // 将 CLI 的 cursor.sh 连接重定向到后端 HTTPS+HTTP/2 监听器。 package cursor import ( "cursor/internal/securefile" _ "embed" "encoding/json" "fmt" "net" "os" "path/filepath" "runtime" "strconv" "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 "" } const cliPreloadFilename = "tls-redirect.cjs" //go:embed cli_preload.cjs var cliPreloadScript string // 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 := securefile.Write(preloadPath, []byte(cliPreloadScript)); 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) if err := securefile.Write(filepath.Join(wrapperDir, "launch.ps1"), append([]byte{0xef, 0xbb, 0xbf}, []byte(buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath))...)); err != nil { return err } } else { content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath) } wrapperPath := CLIWrapperPath() if err := securefile.Write(wrapperPath, []byte(content)); err != nil { return fmt.Errorf("写入 wrapper 脚本失败: %w", err) } if err := os.Chmod(wrapperPath, 0700); err != nil { return 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 { for _, ownedName := range []string{"launch.ps1", "tls-bypass.cjs"} { if err := os.Remove(filepath.Join(CLIWrapperDir(), ownedName)); err != nil && !os.IsNotExist(err) { return err } } 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 { return "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"%~dp0launch.ps1\" %*\r\n" } func buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath string) string { psScript := filepath.Join(filepath.Dir(cliPath), "cursor-agent.ps1") return fmt.Sprintf(`$ErrorActionPreference='Stop' $env:HTTP_PROXY='' $env:HTTPS_PROXY='' $env:CLI_HTTPS_PORT=%s $env:LOCAL_CURSOR_ORIGINAL_CA_FILE=$env:NODE_EXTRA_CA_CERTS $env:NODE_EXTRA_CA_CERTS=%s if($env:LOCAL_CURSOR_ORIGINAL_CA_FILE -eq $env:NODE_EXTRA_CA_CERTS){$env:LOCAL_CURSOR_ORIGINAL_CA_FILE=''} Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue if($env:NODE_OPTIONS -match 'tls-bypass[.]cjs'){$env:NODE_OPTIONS=''} $env:NODE_OPTIONS=($env:NODE_OPTIONS + ' ' + %s).Trim() $env:CURSOR_AUTH_TOKEN=%s $env:CURSOR_INVOKED_AS='agent.cmd' & %s @args exit $LASTEXITCODE `, psLiteral(port), psLiteral(caCertPath), psLiteral("--require="+strconv.Quote(preloadPath)), psLiteral(authToken), psLiteral(psScript)) } func psLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } func shellLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" } func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string { return fmt.Sprintf(`#!/bin/sh unset HTTP_PROXY HTTPS_PROXY NODE_TLS_REJECT_UNAUTHORIZED export CLI_HTTPS_PORT=%s export LOCAL_CURSOR_ORIGINAL_CA_FILE="${NODE_EXTRA_CA_CERTS-}" export NODE_EXTRA_CA_CERTS=%s if [ "$LOCAL_CURSOR_ORIGINAL_CA_FILE" = "$NODE_EXTRA_CA_CERTS" ]; then unset LOCAL_CURSOR_ORIGINAL_CA_FILE; fi case "${NODE_OPTIONS-}" in *tls-bypass.cjs*) unset NODE_OPTIONS;; esac export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }"%s export CURSOR_AUTH_TOKEN=%s exec %s "$@" `, shellLiteral(port), shellLiteral(caCertPath), shellLiteral("--require="+strconv.Quote(preloadPath)), shellLiteral(authToken), shellLiteral(cliPath)) }