дȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 д Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25 AS build
|
||||
FROM golang:1.26.10 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
# 独立 Tab 补全服务
|
||||
|
||||
服务监听 18041,支持远程部署。配置文件必须包含两种不同的令牌:
|
||||
|
||||
```yaml
|
||||
token: "你的上游 Cursor 访问令牌"
|
||||
clientToken: "另行生成的随机客户端访问密钥"
|
||||
```
|
||||
|
||||
调用方使用 `Authorization: Bearer <clientToken>`,Local-Cursor 中对应 `tabServerToken`。服务验证客户端后,用上游 token 转发请求。不要共享上游令牌作为客户端密钥;对外部署请通过 HTTPS 反向代理提供服务。
|
||||
|
||||
仅接受 POST 和已配置的 RPC 路由,单次请求最大 64 MiB。缺少或错误的客户端凭据返回 401。配置或令牌不会写入日志。
|
||||
|
||||
### 如何获取 token?
|
||||
**macos**
|
||||
```bash
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
token: "eyJ..."
|
||||
# Separate random caller secret; configure the same value as tabServerToken.
|
||||
clientToken: ""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
module cursor-tab-server
|
||||
|
||||
go 1.25
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.10
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -58,7 +60,8 @@ var defaultUpstreamTargets = map[string]string{
|
||||
}
|
||||
|
||||
type appConfig struct {
|
||||
Token string
|
||||
Token string `yaml:"token"`
|
||||
ClientToken string `yaml:"clientToken"`
|
||||
}
|
||||
|
||||
type serverApp struct {
|
||||
@@ -97,9 +100,65 @@ func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string
|
||||
return app
|
||||
}
|
||||
|
||||
const maxRequestBytes int64 = 64 << 20
|
||||
|
||||
type trackingWriter struct {
|
||||
http.ResponseWriter
|
||||
started bool
|
||||
}
|
||||
|
||||
func (w *trackingWriter) WriteHeader(code int) {
|
||||
if !w.started {
|
||||
w.started = true
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
func (w *trackingWriter) Write(p []byte) (int, error) {
|
||||
if !w.started {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
func (w *trackingWriter) Flush() {
|
||||
if !w.started {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.handleProxy(writer, request); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadGateway)
|
||||
w := &trackingWriter{ResponseWriter: writer}
|
||||
expected := formatBearerAuthorization(app.config.ClientToken)
|
||||
if app.config.ClientToken == "" || subtle.ConstantTimeCompare([]byte(request.Header.Get("Authorization")), []byte(expected)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if request.ContentLength > maxRequestBytes {
|
||||
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(w, request.Body, maxRequestBytes)
|
||||
if err := app.handleProxy(w, request); err != nil {
|
||||
if !w.started {
|
||||
status := http.StatusBadGateway
|
||||
var limit *http.MaxBytesError
|
||||
if errors.As(err, &limit) {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
status = 499
|
||||
}
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
} else {
|
||||
log.Printf("流式转发中断 path=%s", request.URL.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,11 +219,19 @@ func loadConfig(path string) (appConfig, error) {
|
||||
if err != nil {
|
||||
return appConfig{}, err
|
||||
}
|
||||
token, err := parseTokenYAML(contents)
|
||||
if err != nil {
|
||||
var cfg appConfig
|
||||
if err = yaml.Unmarshal(contents, &cfg); err != nil {
|
||||
return appConfig{}, err
|
||||
}
|
||||
return appConfig{Token: token}, nil
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
cfg.ClientToken = strings.TrimSpace(cfg.ClientToken)
|
||||
if cfg.Token == "" || cfg.ClientToken == "" {
|
||||
return appConfig{}, fmt.Errorf("token 和独立 clientToken 均不能为空")
|
||||
}
|
||||
if cfg.Token == cfg.ClientToken {
|
||||
return appConfig{}, fmt.Errorf("clientToken 必须与上游 token 不同")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseTokenYAML(contents []byte) (string, error) {
|
||||
@@ -277,7 +344,9 @@ func buildCursorChecksum(authorization string) string {
|
||||
}
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
return &http.Client{}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.ResponseHeaderTimeout = 30 * time.Second
|
||||
return &http.Client{Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
}
|
||||
|
||||
func cloneUpstreamTargets(input map[string]string) map[string]string {
|
||||
|
||||
131
cursor-tab-server/main_test.go
Normal file
131
cursor-tab-server/main_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorizationAndUpstreamTokenSeparation(t *testing.T) {
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-secret" {
|
||||
t.Error("wrong upstream token")
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
app := newServerApp(appConfig{Token: "upstream-secret", ClientToken: "client-secret"}, upstream.Client(), map[string]string{"/rpc": upstream.URL})
|
||||
for _, token := range []string{"", "Bearer wrong", "Bearer upstream-secret", "Bearer client-secret"} {
|
||||
r := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader("request"))
|
||||
r.Header.Set("Authorization", token)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
want := 401
|
||||
if token == "Bearer client-secret" {
|
||||
want = 200
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("status=%d want=%d", w.Code, want)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatal("unauthorized request reached upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeRejectedBeforeUpstream(t *testing.T) {
|
||||
app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, nil, map[string]string{"/rpc": "http://127.0.0.1:1"})
|
||||
r := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(""))
|
||||
r.ContentLength = maxRequestBytes + 1
|
||||
r.Header.Set("Authorization", "Bearer client")
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 413 {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type testTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (t testTransport) RoundTrip(r *http.Request) (*http.Response, error) { return t(r) }
|
||||
|
||||
type brokenStream struct{ sent bool }
|
||||
|
||||
func (b *brokenStream) Read(p []byte) (int, error) {
|
||||
if !b.sent {
|
||||
b.sent = true
|
||||
return copy(p, []byte("partial-event")), nil
|
||||
}
|
||||
return 0, errors.New("stream interrupted")
|
||||
}
|
||||
func (b *brokenStream) Close() error { return nil }
|
||||
|
||||
func TestStreamErrorNeverAppendsHTTPError(t *testing.T) {
|
||||
client := &http.Client{Transport: testTransport(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: &brokenStream{}}, nil
|
||||
})}
|
||||
app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, client, map[string]string{"/rpc": "https://upstream.invalid"})
|
||||
r := httptest.NewRequest(http.MethodPost, "/rpc", nil)
|
||||
r.Header.Set("Authorization", "Bearer client")
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 200 || w.Body.String() != "partial-event" {
|
||||
t.Fatalf("response corrupted: %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledRequestDoesNotCallUpstream(t *testing.T) {
|
||||
client := &http.Client{Transport: testTransport(func(r *http.Request) (*http.Response, error) { return nil, r.Context().Err() })}
|
||||
app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, client, map[string]string{"/rpc": "https://upstream.invalid"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
r := httptest.NewRequest(http.MethodPost, "/rpc", nil).WithContext(ctx)
|
||||
r.Header.Set("Authorization", "Bearer client")
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 499 {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTokenRequiredAndDistinct(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||
for _, content := range []string{"token: upstream\n", "token: same\nclientToken: same\n"} {
|
||||
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadConfig(p); err == nil {
|
||||
t.Fatal("insecure configuration accepted")
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(p, []byte("token: upstream\nclientToken: client\n"), 0600)
|
||||
if _, err := loadConfig(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamRedirectDoesNotForwardSecret(t *testing.T) {
|
||||
leaked := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true; _, _ = io.WriteString(w, "bad") }))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) }))
|
||||
defer redirect.Close()
|
||||
r, _ := http.NewRequest(http.MethodPost, redirect.URL, strings.NewReader(""))
|
||||
r.Header.Set("Authorization", "Bearer secret")
|
||||
resp, err := newHTTPClient().Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 307 || leaked {
|
||||
t.Fatal("redirect followed with credentials")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user